mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
673
.github/scripts/scan-malware.js
vendored
Normal file
673
.github/scripts/scan-malware.js
vendored
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Malicious Code Scanner
|
||||||
|
* Detects obfuscated droppers, eval-based loaders, suspicious global assignments,
|
||||||
|
* blockchain C2 patterns, high-entropy payload strings, and stealthy child processes.
|
||||||
|
*
|
||||||
|
* Exit codes: 0 = clean, 1 = threats found, 2 = scanner error
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const EXTENSIONS_TO_SCAN = new Set([
|
||||||
|
".js",
|
||||||
|
".cjs",
|
||||||
|
".mjs",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".jsx",
|
||||||
|
".json",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".vue",
|
||||||
|
".svelte",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ALWAYS_SKIP = new Set([
|
||||||
|
"node_modules",
|
||||||
|
".git",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
".next",
|
||||||
|
".nuxt",
|
||||||
|
"coverage",
|
||||||
|
".nyc_output",
|
||||||
|
"__pycache__",
|
||||||
|
"scan-malware.js",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ENTROPY_THRESHOLD = 5.2; // Shannon bits/char – high = likely encoded payload
|
||||||
|
const ENTROPY_MIN_STRING_LEN = 64; // only test strings at least this long
|
||||||
|
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // skip files > 2 MB
|
||||||
|
|
||||||
|
// ─── Detection Rules ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each rule: { id, description, severity, test(content, filePath) }
|
||||||
|
* test() returns null | { line, snippet }[]
|
||||||
|
*/
|
||||||
|
const RULES = [
|
||||||
|
// ── 1. Global require/module hijacking ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "GLOBAL_REQUIRE_ASSIGN",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Assigns require/module/process to a global slot to survive closure boundaries",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
// Quoted-key form: global['literal'] = require
|
||||||
|
/global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*require\b/g,
|
||||||
|
/global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*module\b/g,
|
||||||
|
/global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*process\b/g,
|
||||||
|
// Computed-key form: global[_$_1e42[0]] = require (real sample pattern)
|
||||||
|
/global\s*\[\s*[^\]]{1,60}\]\s*=\s*require\b/g,
|
||||||
|
/global\s*\[\s*[^\]]{1,60}\]\s*=\s*module\b/g,
|
||||||
|
/global\s*\[\s*[^\]]{1,60}\]\s*=\s*process\b/g,
|
||||||
|
// Dot form: global.x = require
|
||||||
|
/global\s*\.\s*\w+\s*=\s*require\b/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 2. Obfuscator fingerprints ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "OBFUSCATOR_VAR_NAMES",
|
||||||
|
severity: "HIGH",
|
||||||
|
description:
|
||||||
|
"Variable names matching known obfuscator output patterns (_$_, _$af…, sfL…)",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(
|
||||||
|
src,
|
||||||
|
[
|
||||||
|
// _$_1e42 family — the exact dropper signature (minHits=1, one hit is definitive)
|
||||||
|
/\b_\$_[0-9a-zA-Z]{4,}\b/g,
|
||||||
|
// _$af163278 style
|
||||||
|
/\b_\$[a-f0-9]{6,}\b/g,
|
||||||
|
// sfL as a standalone identifier (the shuffler function name in this dropper family)
|
||||||
|
/\bsfL\b/g,
|
||||||
|
// sfLxxx variants
|
||||||
|
/\bsfL[A-Za-z0-9]{2,}\b/g,
|
||||||
|
// generic hex-suffix identifiers (broader catch for other obfuscators)
|
||||||
|
/\b[a-zA-Z]{1,3}[0-9a-f]{8,}\b/g,
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
); // one strong match is enough — these patterns don't appear in legitimate code
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 3. Function-constructor eval-by-constructor ──────────────────────────────
|
||||||
|
{
|
||||||
|
id: "FUNCTION_CONSTRUCTOR_EVAL",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Dynamically constructs and executes code via the Function constructor",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
// Explicit new Function(...)
|
||||||
|
/new\s+Function\s*\(\s*[^)]{40,}\)/g,
|
||||||
|
/Function\s*\(\s*['"`][^'"`]{40,}['"`]\s*\)\s*\(\)/g,
|
||||||
|
/\(\s*new\s+Function\s*\(/g,
|
||||||
|
/\bFunction\b[^(]*\([^)]*\)\s*\(\s*\)/g,
|
||||||
|
// ── Stolen-property pattern (the real sample's technique) ──
|
||||||
|
// var x = fn[computed] then x('', decode(bigString))
|
||||||
|
// Step 1: extract constructor via computed property on a function
|
||||||
|
/var\s+\w+\s*=\s*\w+\s*\[\s*\w+\s*\]\s*;[\s\S]{0,120}var\s+\w+\s*=\s*\w+\s*\(\s*(?:''|""|``|\w+)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g,
|
||||||
|
// Step 2: direct two-arg call with empty first arg (how Function ctor is invoked)
|
||||||
|
/\w+\s*\(\s*(?:''|""|``)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g,
|
||||||
|
// Step 3: fn[computed](empty, decoder(str)) in one expression
|
||||||
|
/\w+\s*\[\s*\w+\s*\]\s*\(\s*(?:''|""|``|\w*)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 4. Encoded-string eval calls ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "EVAL_ENCODED_STRING",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description: "Passes a large encoded/obfuscated literal directly to eval()",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/\beval\s*\(\s*['"`][A-Za-z0-9+/=%\\]{80,}['"`]\s*\)/g,
|
||||||
|
/\beval\s*\(\s*[A-Za-z_$][A-Za-z0-9_$]*\s*\(\s*[^)]{0,60}\)\s*\)/g, // eval(decode(...))
|
||||||
|
/\beval\s*\(\s*atob\s*\(/g,
|
||||||
|
/\beval\s*\(\s*Buffer\s*\.from\s*\(/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 5. Plain suspicious eval ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "SUSPICIOUS_EVAL",
|
||||||
|
severity: "MEDIUM",
|
||||||
|
description: "eval() used in a context that suggests dynamic code loading",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/\beval\s*\(\s*(?!\/[\/*])(?!\s*['"`]\s*['"`])[^;)]{20,}\)/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 6. Stealthy child_process spawn ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "DETACHED_CHILD_PROCESS",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Spawns a detached, stdio-less child process — classic dropper persistence",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/detached\s*:\s*true/g,
|
||||||
|
/stdio\s*:\s*['"`]ignore['"`]/g,
|
||||||
|
/windowsHide\s*:\s*true/g,
|
||||||
|
/spawn\s*\(\s*['"`]node['"`]\s*,\s*\[\s*['"`]-e['"`]/g,
|
||||||
|
/execFile\s*\([^)]+detached/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 7. Blockchain C2 endpoints ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "BLOCKCHAIN_C2",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Contacts blockchain APIs (TronGrid, Aptos, etc.) to retrieve a payload",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/trongrid\.io/gi,
|
||||||
|
/tronscan\.org/gi,
|
||||||
|
/fullnode\.mainnet\.aptoslabs\.com/gi,
|
||||||
|
/aptos\.dev\/v1\/accounts/gi,
|
||||||
|
/getTransactionInfo|getTransactionById/g,
|
||||||
|
/\.resource\.data\.value\b/g, // Aptos on-chain data access pattern
|
||||||
|
/wallet_address.*blockchain|blockchain.*wallet_address/gi,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 8. XOR-based decryption of a payload ────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "XOR_PAYLOAD_DECRYPT",
|
||||||
|
severity: "HIGH",
|
||||||
|
description:
|
||||||
|
"XOR-decryption loop over a fetched or hardcoded payload buffer",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/charCodeAt\s*\([^)]*\)\s*\^\s*\w+\.charCodeAt\s*\(/g,
|
||||||
|
/\^\s*key\.charCodeAt\s*\(/g,
|
||||||
|
/\.map\s*\(\s*\([^)]*\)\s*=>\s*[^.]+\.\s*charCodeAt[^)]*\s*\^/g,
|
||||||
|
/fromCharCode\s*\([^)]*\^[^)]*\)/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 9. String.fromCharCode(127) split (known dropper separator) ───────────────
|
||||||
|
{
|
||||||
|
id: "FROMCHARCODE_SEPARATOR",
|
||||||
|
severity: "HIGH",
|
||||||
|
description:
|
||||||
|
"Uses String.fromCharCode() as a string delimiter/split marker",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/String\.fromCharCode\s*\(\s*1[0-2][0-9]\s*\)/g, // DEL, extended ctrl chars
|
||||||
|
/\.split\s*\(\s*String\.fromCharCode\s*\(/g,
|
||||||
|
/String\.fromCharCode\s*\(\s*0\s*\)/g, // null byte as delimiter
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 10. Rate-limiting / re-execution guard (anti-detection) ──────────────────
|
||||||
|
{
|
||||||
|
id: "EXECUTION_RATE_LIMIT",
|
||||||
|
severity: "MEDIUM",
|
||||||
|
description:
|
||||||
|
"Hardcoded timing gate (30 s window) used to avoid repeated execution",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/Date\.now\s*\(\)\s*-\s*\w+\s*[<>]=?\s*3000[0-9]/g, // 30 000 ms
|
||||||
|
/setTimeout[^)]+3[0-9]{4}/g,
|
||||||
|
/lastRun|_lastExec|_rateLimit|__ts/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 11. High-entropy string literals ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "HIGH_ENTROPY_STRING",
|
||||||
|
severity: "HIGH",
|
||||||
|
description:
|
||||||
|
"Long string literal with entropy above threshold — likely encoded payload",
|
||||||
|
test(src, filePath) {
|
||||||
|
// Skip minified bundles and lockfiles
|
||||||
|
if (/\.(min\.js|lock|map)$/.test(filePath)) return null;
|
||||||
|
const hits = [];
|
||||||
|
// Broader charset: the real dropper's joW/pYd blobs contain spaces, brackets,
|
||||||
|
// semicolons, etc. — any non-newline, non-quote content of 100+ chars qualifies.
|
||||||
|
// We use two regexes: one for tightly-packed base64-like strings, one for the
|
||||||
|
// looser mixed-content payload strings this dropper family actually uses.
|
||||||
|
const patterns = [
|
||||||
|
// Tight: no spaces (base64, hex, classic obfuscation)
|
||||||
|
/(['"`])([A-Za-z0-9+/=\\%^&*!@#$\-_.~]{64,})\1/g,
|
||||||
|
// Loose: mixed printable chars including spaces — catches joW / pYd style blobs
|
||||||
|
/(['"`])([^'"` \t\r\n]{50,}[^'"` \t\r\n])\1/g,
|
||||||
|
// Single-quoted with internal spaces — the exact form used in this dropper
|
||||||
|
/'([^'\r\n]{100,})'/g,
|
||||||
|
];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const strRe of patterns) {
|
||||||
|
strRe.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = strRe.exec(src)) !== null) {
|
||||||
|
const s =
|
||||||
|
m[1] !== undefined && m[1].length === 1 ? (m[2] ?? m[1]) : m[1];
|
||||||
|
const payload = typeof s === "string" ? s : m[0].slice(1, -1);
|
||||||
|
if (payload.length < 50) continue;
|
||||||
|
const key = payload.slice(0, 32); // dedup by prefix
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
if (shannonEntropy(payload) >= ENTROPY_THRESHOLD) {
|
||||||
|
hits.push({
|
||||||
|
line: lineOf(src, m.index),
|
||||||
|
snippet: payload.slice(0, 80) + "…",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits.length ? hits : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 15. Global nonce / infection marker ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "GLOBAL_NONCE_MARKER",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Sets a short global marker string (e.g. global['!']='8-3946') as an infection flag / re-execution guard",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
// global['!'] = '8-3946' or global["x"] = "abc-123"
|
||||||
|
/global\s*\[\s*['"][^'"]{0,5}['"]\s*\]\s*=\s*['"][0-9!@#$%^&*\-]{3,20}['"]/g,
|
||||||
|
// global['!']='...' with no spaces (minified form)
|
||||||
|
/global\['[^']{0,5}'\]='[^']{2,20}'/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 16. String-shuffler IIFE ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "STRING_SHUFFLER_IIFE",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Self-invoking string-shuffler function (seeded character-swap loop) used to decode obfuscated identifiers and payloads",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
// The core pattern: (function(x,y){ ... charAt ... % bignum ... })(str, bigint)
|
||||||
|
/\(function\s*\(\s*\w\s*,\s*\w\s*\)\s*\{[^}]{30,}charAt[^}]{10,}%\s*[0-9]{5,}/g,
|
||||||
|
// Seeded arithmetic inside a loop: e = (s + w) % bignum
|
||||||
|
/[a-z]\s*=\s*\(\s*[a-z]\s*\+\s*[a-z]\s*\)\s*%\s*[0-9]{6,}/g,
|
||||||
|
// The characteristic swap: var y=g[t]; g[t]=g[p]; g[p]=y
|
||||||
|
/var\s+\w\s*=\s*\w\s*\[\s*\w\s*\]\s*;\s*\w\s*\[\s*\w\s*\]\s*=\s*\w\s*\[\s*\w\s*\]\s*;\s*\w\s*\[\s*\w\s*\]\s*=\s*\w/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 17. Method-extraction constructor theft ───────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "CONSTRUCTOR_THEFT",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Extracts the Function constructor via a computed property on a function object (e.g. sfL['constructor']), bypassing direct 'Function' keyword detection",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(
|
||||||
|
src,
|
||||||
|
[
|
||||||
|
// var EKc = sfL('...').substr(0, N) — computing the property name 'constructor'
|
||||||
|
/\w+\s*\([^)]{5,50}\)\.substr\s*\(\s*0\s*,\s*\w+\s*\)/g,
|
||||||
|
// var dgC = sfL[EKc] — stealing the constructor via computed key
|
||||||
|
/var\s+\w+\s*=\s*\w+\s*\[\s*\w+\s*\]/g,
|
||||||
|
// multi-step: var x=fn[computed]; var y=x; var z=x(empty, decode(blob))
|
||||||
|
/var\s+\w+\s*=\s*\w+;\s*var\s+\w+\s*=\s*\w+\s*\(\s*(?:\w+|''|"")\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g,
|
||||||
|
],
|
||||||
|
2,
|
||||||
|
); // need ≥2: the substr alone can appear legitimately, but substr + bracket-access together is the tell
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 18. Multi-step join/split decode chain ────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "JOIN_SPLIT_DECODE_CHAIN",
|
||||||
|
severity: "HIGH",
|
||||||
|
description:
|
||||||
|
"Repeated join/split/join sequence used to reassemble an obfuscated string — characteristic of this dropper family",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
// .join(x).split(y).join(z) — at least two chained steps
|
||||||
|
/\.join\s*\([^)]{0,20}\)\s*\.split\s*\([^)]{0,20}\)\s*\.join\s*\([^)]{0,20}\)/g,
|
||||||
|
// Three+ steps (the real sample has 4): .join.split.join.split.join
|
||||||
|
/(?:\.join\s*\([^)]{0,20}\)\s*\.split\s*\([^)]{0,20}\)\s*){2,}/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 19. typeof-against-dynamic-string ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "TYPEOF_DYNAMIC_CHECK",
|
||||||
|
severity: "HIGH",
|
||||||
|
description:
|
||||||
|
"Uses typeof x === decoded_var[n] instead of typeof x === 'object' to hide the string 'object' from static analysis",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
// typeof module === _$_1e42[1]
|
||||||
|
/typeof\s+\w+\s*===\s*_\$_/g,
|
||||||
|
// typeof x === anyVar[digit]
|
||||||
|
/typeof\s+\w+\s*===\s*\w+\s*\[\s*\d+\s*\]/g,
|
||||||
|
// typeof x === dynamicVar (no bracket, just a variable holding the type string)
|
||||||
|
/typeof\s+(?:module|require|process|exports)\s*===\s*[A-Za-z_$][A-Za-z0-9_$]*(?!\s*[[(])/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 20. IIFE with numeric seed + numeric return (dropper wrapper) ──────────────
|
||||||
|
{
|
||||||
|
id: "DROPPER_IIFE_WRAPPER",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Top-level IIFE that calls the final compiled payload with a numeric seed and returns a fake numeric value — canonical dropper wrapper structure",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
// Tgw(2509); return 1358})()
|
||||||
|
/\w+\s*\(\s*\d{4,5}\s*\)\s*;\s*return\s+\d{3,6}\s*\}\s*\)\s*\(\s*\)/g,
|
||||||
|
// return NNNN})() — the fake return at the end of the outer IIFE
|
||||||
|
/return\s+\d{3,6}\s*\}\s*\)\s*\(\s*\)/g,
|
||||||
|
// (function(){...})() containing a numeric final call + numeric return
|
||||||
|
/\w+\s*\(\s*[0-9]{4}\s*\)[^)]*return\s+[0-9]{4}/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 12. Dynamic property access on require/module ────────────────────────────
|
||||||
|
{
|
||||||
|
id: "DYNAMIC_REQUIRE",
|
||||||
|
severity: "HIGH",
|
||||||
|
description: "require() called with a computed or obfuscated argument",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/require\s*\(\s*\w+\s*\[\s*\d+\s*\]\s*\)/g, // require(arr[0])
|
||||||
|
/require\s*\(\s*[A-Za-z_$]+\s*\(\s*[^)]{20,}\)\s*\)/g, // require(decode(...))
|
||||||
|
/\[['"`]require['"`]\]\s*\(/g, // ['require'](...)
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 13. Self-deletion / evidence wiping ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "SELF_DELETE",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description: "File deletes itself or wipes evidence after running",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/fs\.unlink.*__filename/g,
|
||||||
|
/fs\.unlinkSync.*__filename/g,
|
||||||
|
/rimraf.*__dirname/g,
|
||||||
|
/process\.argv\[1\].*unlink/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 14. Exfiltration patterns ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: "EXFILTRATION_PATTERN",
|
||||||
|
severity: "HIGH",
|
||||||
|
description:
|
||||||
|
"Reads sensitive files or env vars and sends them over the network",
|
||||||
|
test(src) {
|
||||||
|
return matchAll(src, [
|
||||||
|
/readFileSync.*\.ssh/g,
|
||||||
|
/readFileSync.*\.aws/g,
|
||||||
|
/readFileSync.*\.env/g,
|
||||||
|
/process\.env\.[A-Z_]{4,}.*fetch|fetch.*process\.env\.[A-Z_]{4,}/g,
|
||||||
|
/HOME.*\.npmrc.*post|post.*HOME.*\.npmrc/g,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shannonEntropy(str) {
|
||||||
|
const freq = {};
|
||||||
|
for (const c of str) freq[c] = (freq[c] || 0) + 1;
|
||||||
|
const len = str.length;
|
||||||
|
return -Object.values(freq).reduce((acc, f) => {
|
||||||
|
const p = f / len;
|
||||||
|
return acc + p * Math.log2(p);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineOf(src, idx) {
|
||||||
|
return src.slice(0, idx).split("\n").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run one or more regexes; return hits if total unique-line matches >= minHits */
|
||||||
|
function matchAll(src, patterns, minHits = 1) {
|
||||||
|
const hits = [];
|
||||||
|
for (const re of patterns) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(src)) !== null) {
|
||||||
|
hits.push({ line: lineOf(src, m.index), snippet: m[0].slice(0, 120) });
|
||||||
|
if (re.lastIndex === m.index) re.lastIndex++; // guard zero-width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hits.length < minHits) return null;
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── File Walking ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function* walk(dir) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (ALWAYS_SKIP.has(entry.name)) continue;
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
yield* walk(full);
|
||||||
|
} else if (
|
||||||
|
entry.isFile() &&
|
||||||
|
EXTENSIONS_TO_SCAN.has(path.extname(entry.name).toLowerCase())
|
||||||
|
) {
|
||||||
|
yield full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Scanner ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function scan(rootDir) {
|
||||||
|
const findings = []; // { file, rule, hits }
|
||||||
|
let scanned = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const filePath of walk(rootDir)) {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (stat.size > MAX_FILE_SIZE_BYTES) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stat.size === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let src;
|
||||||
|
try {
|
||||||
|
src = fs.readFileSync(filePath, "utf8");
|
||||||
|
} catch {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned++;
|
||||||
|
const relPath = path.relative(rootDir, filePath);
|
||||||
|
|
||||||
|
for (const rule of RULES) {
|
||||||
|
try {
|
||||||
|
const hits = rule.test(src, filePath);
|
||||||
|
if (hits && hits.length > 0) {
|
||||||
|
findings.push({ file: relPath, rule, hits });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Never let a broken rule crash the whole scan
|
||||||
|
process.stderr.write(
|
||||||
|
`[WARN] Rule ${rule.id} threw on ${relPath}: ${err.message}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { findings, scanned, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reporting ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
||||||
|
const SEVERITY_COLOR = {
|
||||||
|
CRITICAL: "\x1b[31;1m", // bold red
|
||||||
|
HIGH: "\x1b[33;1m", // bold yellow
|
||||||
|
MEDIUM: "\x1b[36m", // cyan
|
||||||
|
LOW: "\x1b[37m", // white
|
||||||
|
};
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
const BOLD = "\x1b[1m";
|
||||||
|
|
||||||
|
function color(sev, text) {
|
||||||
|
if (!process.stdout.isTTY) return text;
|
||||||
|
return `${SEVERITY_COLOR[sev] || ""}${text}${RESET}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function report({ findings, scanned, skipped }) {
|
||||||
|
const sorted = [...findings].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(SEVERITY_ORDER[a.rule.severity] ?? 9) -
|
||||||
|
(SEVERITY_ORDER[b.rule.severity] ?? 9),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\n" + "═".repeat(72));
|
||||||
|
console.log(`${BOLD} Malicious Code Scanner — Results${RESET}`);
|
||||||
|
console.log("═".repeat(72));
|
||||||
|
console.log(` Files scanned : ${scanned}`);
|
||||||
|
console.log(` Files skipped : ${skipped}`);
|
||||||
|
console.log(` Findings : ${sorted.length}`);
|
||||||
|
console.log("─".repeat(72));
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
console.log("\n ✅ No suspicious patterns detected.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by severity
|
||||||
|
const bySeverity = {};
|
||||||
|
for (const f of sorted) {
|
||||||
|
(bySeverity[f.rule.severity] ??= []).push(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sev of ["CRITICAL", "HIGH", "MEDIUM", "LOW"]) {
|
||||||
|
const group = bySeverity[sev];
|
||||||
|
if (!group) continue;
|
||||||
|
console.log(`\n ${color(sev, `── ${sev} (${group.length})`)}`);
|
||||||
|
for (const { file, rule, hits } of group) {
|
||||||
|
console.log(`\n ${BOLD}${file}${RESET}`);
|
||||||
|
console.log(` Rule : ${rule.id}`);
|
||||||
|
console.log(` Detail : ${rule.description}`);
|
||||||
|
const shown = hits.slice(0, 3);
|
||||||
|
for (const h of shown) {
|
||||||
|
console.log(` Line ~${h.line}: ${color(sev, h.snippet)}`);
|
||||||
|
}
|
||||||
|
if (hits.length > 3)
|
||||||
|
console.log(` … and ${hits.length - 3} more occurrences`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary counts
|
||||||
|
const counts = Object.fromEntries(
|
||||||
|
["CRITICAL", "HIGH", "MEDIUM", "LOW"].map((s) => [
|
||||||
|
s,
|
||||||
|
(bySeverity[s] || []).length,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\n" + "─".repeat(72));
|
||||||
|
console.log(
|
||||||
|
` Summary: ` +
|
||||||
|
color("CRITICAL", `${counts.CRITICAL} CRITICAL`) +
|
||||||
|
" " +
|
||||||
|
color("HIGH", `${counts.HIGH} HIGH`) +
|
||||||
|
" " +
|
||||||
|
color("MEDIUM", `${counts.MEDIUM} MEDIUM`) +
|
||||||
|
" " +
|
||||||
|
`${counts.LOW} LOW`,
|
||||||
|
);
|
||||||
|
console.log("═".repeat(72) + "\n");
|
||||||
|
|
||||||
|
// Fail CI on CRITICAL or HIGH
|
||||||
|
return counts.CRITICAL + counts.HIGH > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JSON output for upload-artifact / SARIF consumers ───────────────────────
|
||||||
|
|
||||||
|
function writeJsonReport(findings, outPath) {
|
||||||
|
const out = findings.map(({ file, rule, hits }) => ({
|
||||||
|
file,
|
||||||
|
rule_id: rule.id,
|
||||||
|
severity: rule.severity,
|
||||||
|
description: rule.description,
|
||||||
|
occurrences: hits,
|
||||||
|
}));
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(out, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Entry point ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const targetArg = process.argv[2] || process.cwd();
|
||||||
|
const jsonOut = process.env.SCAN_JSON_OUT || "";
|
||||||
|
|
||||||
|
// Accept either a directory OR a single file as the scan target
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
const targetStat = fs.statSync(targetArg);
|
||||||
|
if (targetStat.isFile()) {
|
||||||
|
// Single-file mode: scan just that file regardless of extension
|
||||||
|
const src = fs.readFileSync(targetArg, "utf8");
|
||||||
|
const findings = [];
|
||||||
|
for (const rule of RULES) {
|
||||||
|
try {
|
||||||
|
const hits = rule.test(src, targetArg);
|
||||||
|
if (hits && hits.length > 0) {
|
||||||
|
findings.push({ file: path.basename(targetArg), rule, hits });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`[WARN] Rule ${rule.id} threw: ${err.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = { findings, scanned: 1, skipped: 0 };
|
||||||
|
} else {
|
||||||
|
result = scan(targetArg);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Scanner internal error: ${err.message}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitCode = report(result);
|
||||||
|
|
||||||
|
if (jsonOut) {
|
||||||
|
try {
|
||||||
|
writeJsonReport(result.findings, jsonOut);
|
||||||
|
console.log(`JSON report written to: ${jsonOut}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to write JSON report: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
3
.github/workflows/deploy.yml
vendored
3
.github/workflows/deploy.yml
vendored
@@ -38,6 +38,9 @@ jobs:
|
|||||||
- project: edr-passenger
|
- project: edr-passenger
|
||||||
build_env_file: passenger-web.build.env
|
build_env_file: passenger-web.build.env
|
||||||
service: passenger-backoffice
|
service: passenger-backoffice
|
||||||
|
- project: edr-payment
|
||||||
|
build_env_file: payment-web.build.env
|
||||||
|
service: payment-api
|
||||||
env:
|
env:
|
||||||
PROJECT: ${{ matrix.project }}
|
PROJECT: ${{ matrix.project }}
|
||||||
BRANCH: ${{ github.ref_name }}
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
|||||||
@@ -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/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-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-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/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 |
|
| `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/portal`: 5173
|
||||||
- `edr-freight-web/backoffice`: 5183
|
- `edr-freight-web/backoffice`: 5183
|
||||||
- `edr-passenger-api`: 3002
|
- `edr-passenger-api`: 3002
|
||||||
|
- `edr-payment-api`: 3003
|
||||||
- `edr-passenger-web/portal`: 5174
|
- `edr-passenger-web/portal`: 5174
|
||||||
- `edr-passenger-web/backoffice`: 5184
|
- `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-freight` (port 5433): database `edr_freight` — freight API only.
|
||||||
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger 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.
|
- 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
|
## Adding a new module to a NestJS app
|
||||||
|
|||||||
@@ -65,13 +65,25 @@ CARD_WEBHOOK_SECRET=
|
|||||||
CARD_WEBHOOK_URL=
|
CARD_WEBHOOK_URL=
|
||||||
CARD_RETURN_URL=
|
CARD_RETURN_URL=
|
||||||
|
|
||||||
# Waafi (Djibouti Mobile Money)
|
# Waafi (Djibouti Mobile Money — Hosted Payment Page)
|
||||||
WAAFI_BASE_URL=https://api.waafipay.net
|
# Sandbox: https://sandbox.waafipay.net | Production: https://api.waafipay.net
|
||||||
|
WAAFI_BASE_URL=https://sandbox.waafipay.net
|
||||||
WAAFI_MERCHANT_UID=
|
WAAFI_MERCHANT_UID=
|
||||||
WAAFI_API_USER_ID=
|
WAAFI_STORE_ID=
|
||||||
WAAFI_API_KEY=
|
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_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 Configuration
|
||||||
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/payment-providers": "workspace:*",
|
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { randomUUID as uuidv4 } from 'crypto';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -224,32 +224,34 @@ async function seedCoaches() {
|
|||||||
create: coach,
|
create: coach,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Idempotently reconcile the coach's seats. Upsert keyed on the
|
||||||
|
// @@unique([coachId, row, col]) constraint so a re-seed updates existing
|
||||||
|
// rows in place instead of deleting them. Deleting Seats fails with a P2003
|
||||||
|
// FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them.
|
||||||
let seatIndex = 1;
|
let seatIndex = 1;
|
||||||
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
|
for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) {
|
||||||
for (const col of ['A', 'B', 'C', 'D']) {
|
for (const col of ['A', 'B', 'C', 'D']) {
|
||||||
if (seatIndex <= coach.capacity) {
|
if (seatIndex > coach.capacity) break;
|
||||||
let bedPosition: string | null = null;
|
let bedPosition: string | null = null;
|
||||||
if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) {
|
if (c.coachTypeId === ecoBedCoachType!.id || c.coachTypeId === vipBedCoachType!.id) {
|
||||||
if (row % 3 === 1) bedPosition = 'upper';
|
if (row % 3 === 1) bedPosition = 'upper';
|
||||||
else if (row % 3 === 2) bedPosition = 'middle';
|
else if (row % 3 === 2) bedPosition = 'middle';
|
||||||
else bedPosition = 'lower';
|
else bedPosition = 'lower';
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.seat.upsert({
|
|
||||||
where: { coachId_seatNumber: { coachId: c.id, seatNumber: seatIndex.toString() } },
|
|
||||||
update: { bedPosition },
|
|
||||||
create: {
|
|
||||||
coachId: c.id,
|
|
||||||
seatNumber: seatIndex.toString(),
|
|
||||||
row,
|
|
||||||
col,
|
|
||||||
isWindow: col === 'A' || col === 'D',
|
|
||||||
isAisle: col === 'B' || col === 'C',
|
|
||||||
bedPosition,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
seatIndex++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seatData = {
|
||||||
|
seatNumber: seatIndex.toString(),
|
||||||
|
isWindow: col === 'A' || col === 'D',
|
||||||
|
isAisle: col === 'B' || col === 'C',
|
||||||
|
bedPosition,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.seat.upsert({
|
||||||
|
where: { coachId_row_col: { coachId: c.id, row, col } },
|
||||||
|
update: seatData,
|
||||||
|
create: { coachId: c.id, row, col, ...seatData },
|
||||||
|
});
|
||||||
|
seatIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
totalSeats += coach.capacity;
|
totalSeats += coach.capacity;
|
||||||
@@ -550,25 +552,49 @@ async function seedFraudRules() {
|
|||||||
console.log(` ✅ ${rules.length} fraud detection rules created`);
|
console.log(` ✅ ${rules.length} fraud detection rules created`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run a seed step in isolation: if it throws (FK conflict, duplicate row,
|
||||||
|
// missing record, etc.) log the error and keep going so the rest of the seed —
|
||||||
|
// and the API startup that follows it — are never blocked by one bad step.
|
||||||
|
async function runStep(name: string, step: () => Promise<unknown>): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await step();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`⚠️ Seed step "${name}" failed — skipping and continuing:`, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
||||||
|
|
||||||
await seedSystemUsers();
|
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||||
await seedStations();
|
['system users', seedSystemUsers],
|
||||||
await seedCoachTypesAndClasses();
|
['stations', seedStations],
|
||||||
await seedRoute();
|
['coach types & classes', seedCoachTypesAndClasses],
|
||||||
await seedCoaches();
|
['route', seedRoute],
|
||||||
await seedTrips();
|
['coaches', seedCoaches],
|
||||||
await seedFareRules();
|
['trips', seedTrips],
|
||||||
await seedCurrency();
|
['fare rules', seedFareRules],
|
||||||
await seedPaymentMethods();
|
['currency', seedCurrency],
|
||||||
await seedNotificationTemplates();
|
['payment methods', seedPaymentMethods],
|
||||||
await seedMenuAndFood();
|
['notification templates', seedNotificationTemplates],
|
||||||
await seedPromotions();
|
['menu & food', seedMenuAndFood],
|
||||||
await seedFAQ();
|
['promotions', seedPromotions],
|
||||||
await seedFraudRules();
|
['FAQ', seedFAQ],
|
||||||
|
['fraud rules', seedFraudRules],
|
||||||
|
];
|
||||||
|
|
||||||
console.log('\n✅ Seed complete!\n');
|
let failed = 0;
|
||||||
|
for (const [name, step] of steps) {
|
||||||
|
if (!(await runStep(name, step))) failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.warn(`\n⚠️ Seed finished with ${failed}/${steps.length} step(s) failed (see logs above).\n`);
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ Seed complete!\n');
|
||||||
|
}
|
||||||
console.log('🔑 System Users:');
|
console.log('🔑 System Users:');
|
||||||
console.log(' Admin: admin@edr-platform.com / admin123');
|
console.log(' Admin: admin@edr-platform.com / admin123');
|
||||||
console.log(' Passenger: kelemu@email.com / password123');
|
console.log(' Passenger: kelemu@email.com / password123');
|
||||||
@@ -579,8 +605,10 @@ async function main() {
|
|||||||
|
|
||||||
main()
|
main()
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error('❌ Seed failed:', e);
|
// Intentionally do NOT process.exit(1): the docker entrypoint runs under
|
||||||
process.exit(1);
|
// `set -e`, so a non-zero exit here would abort container startup and the
|
||||||
|
// API would never boot. Log and exit cleanly instead.
|
||||||
|
console.error('❌ Seed crashed unexpectedly — continuing so the API can start:', e);
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared-secret guard for endpoints only the payment microservice may call
|
||||||
|
* (e.g. /internal/payments/mark-paid). The secret is the same SERVICE_AUTH_TOKEN the
|
||||||
|
* payment service enforces on its own internal surface. A forged mark-paid must not be able
|
||||||
|
* to confirm a booking without a real payment.
|
||||||
|
* TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ServiceAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(ServiceAuthGuard.name);
|
||||||
|
private readonly token = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||||
|
private warned = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!this.token && process.env.NODE_ENV === "production") {
|
||||||
|
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
if (!this.token) {
|
||||||
|
if (!this.warned) {
|
||||||
|
this.logger.warn(
|
||||||
|
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
|
||||||
|
);
|
||||||
|
this.warned = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const header = request.headers["x-service-token"];
|
||||||
|
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
|
||||||
|
const presented =
|
||||||
|
(Array.isArray(header) ? header[0] : header) ?? bearer ?? "";
|
||||||
|
|
||||||
|
const expected = Buffer.from(this.token);
|
||||||
|
const actual = Buffer.from(presented);
|
||||||
|
const valid =
|
||||||
|
expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||||
|
if (!valid) throw new UnauthorizedException("Invalid service token");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
import { registerAs } from '@nestjs/config';
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
export default registerAs('waafi', () => ({
|
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 ?? '',
|
merchantUid: process.env.WAAFI_MERCHANT_UID ?? '',
|
||||||
apiUserId: process.env.WAAFI_API_USER_ID ?? '',
|
storeId: process.env.WAAFI_STORE_ID ?? '',
|
||||||
apiKey: process.env.WAAFI_API_KEY ?? '',
|
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 ?? '',
|
notifyUrl: process.env.WAAFI_NOTIFY_URL ?? '',
|
||||||
returnUrl: process.env.WAAFI_RETURN_URL ?? '',
|
// DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that
|
||||||
|
// does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod.
|
||||||
|
insecureTls: process.env.WAAFI_INSECURE_TLS === 'true',
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { ResponseTransformInterceptor } from "./common/interceptors/response-tra
|
|||||||
import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor";
|
import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor";
|
||||||
|
|
||||||
async function bootstrap() {
|
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({
|
app.enableCors({
|
||||||
origin: [
|
origin: [
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
|
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||||
|
import { PaymentsService } from "./payments.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumer side of the payment microservice's outbox relay (docs/payment-service §7.3).
|
||||||
|
* Only the payment service may call this (shared service token). Idempotent by design:
|
||||||
|
* the relay delivers at-least-once, so duplicates must be harmless. Becomes a queue
|
||||||
|
* consumer when RabbitMQ lands — the handler logic is transport-agnostic.
|
||||||
|
*/
|
||||||
|
@ApiTags("Internal Payments")
|
||||||
|
@UseGuards(ServiceAuthGuard)
|
||||||
|
@Controller("internal/payments")
|
||||||
|
export class InternalPaymentsController {
|
||||||
|
constructor(private readonly paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
|
@Post("mark-paid")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Apply a payment.succeeded/payment.failed event from the payment service (idempotent)",
|
||||||
|
})
|
||||||
|
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||||
|
return this.paymentsService.handlePaymentEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsISO8601,
|
||||||
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
} from "class-validator";
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
PaymentEventType,
|
||||||
|
PaymentReferenceType,
|
||||||
|
PaymentService,
|
||||||
|
ProviderMethod,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shape of the `PaymentEvent` envelope (@edr/types) delivered by the payment
|
||||||
|
* microservice's outbox relay. Delivery is at-least-once — the consumer is idempotent.
|
||||||
|
*/
|
||||||
|
export class PaymentEventDto {
|
||||||
|
@ApiProperty({ enum: [1] }) @IsIn([1]) version!: 1;
|
||||||
|
@ApiProperty() @IsUUID() eventId!: string;
|
||||||
|
@ApiProperty({ enum: ["payment.succeeded", "payment.failed"] })
|
||||||
|
@IsIn(["payment.succeeded", "payment.failed"])
|
||||||
|
eventType!: PaymentEventType;
|
||||||
|
|
||||||
|
@ApiProperty() @IsISO8601() occurredAt!: string;
|
||||||
|
@ApiProperty({ enum: PaymentService })
|
||||||
|
@IsEnum(PaymentService)
|
||||||
|
service!: string;
|
||||||
|
@ApiProperty() @IsUUID() intentId!: string;
|
||||||
|
@ApiProperty({ enum: PaymentReferenceType })
|
||||||
|
@IsEnum(PaymentReferenceType)
|
||||||
|
referenceType!: string;
|
||||||
|
|
||||||
|
@ApiProperty() @IsString() referenceId!: string;
|
||||||
|
@ApiProperty() @IsString() merchantOrderId!: string;
|
||||||
|
@ApiProperty({ enum: ProviderMethod })
|
||||||
|
@IsEnum(ProviderMethod)
|
||||||
|
provider!: string;
|
||||||
|
@ApiProperty() @IsInt() @IsPositive() amountMinor!: number;
|
||||||
|
@ApiProperty() @IsString() currency!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsISO8601() paidAt?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() failureCode?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() failureMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkPaidResponseDto {
|
||||||
|
@ApiProperty() processed!: boolean;
|
||||||
|
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||||
|
@ApiPropertyOptional() reason?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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}`;
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.request<T>({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
data: body,
|
||||||
|
headers: this.serviceToken
|
||||||
|
? { "x-service-token": this.serviceToken }
|
||||||
|
: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError && err.response) {
|
||||||
|
// 4xx/5xx from the payment service: propagate 404 to callers that handle it;
|
||||||
|
// everything else is a gateway-level failure from the client's perspective.
|
||||||
|
if (err.response.status === 404) throw err;
|
||||||
|
const detail =
|
||||||
|
(err.response.data as { message?: string | string[] })?.message ??
|
||||||
|
err.message;
|
||||||
|
this.logger.error(
|
||||||
|
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
err instanceof Error && err.message ? err.message : String(err);
|
||||||
|
this.logger.error(
|
||||||
|
`payment service unreachable (${method} ${path}): ${message}`,
|
||||||
|
);
|
||||||
|
throw new BadGatewayException("Payment service unreachable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,50 @@
|
|||||||
export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; }
|
export interface GatewayResult {
|
||||||
|
success: boolean;
|
||||||
export async function telebirrAdapter(_a: number, ref: string): Promise<GatewayResult> {
|
providerRef: string;
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
clientAction?: { type: string; url?: string };
|
||||||
return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } };
|
}
|
||||||
|
|
||||||
|
export async function telebirrAdapter(
|
||||||
|
_a: number,
|
||||||
|
ref: string,
|
||||||
|
): Promise<GatewayResult> {
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
providerRef: `TB-${ref}-${Date.now()}`,
|
||||||
|
clientAction: {
|
||||||
|
type: "REDIRECT",
|
||||||
|
url: `https://telebirr.sandbox.com/pay/${ref}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function cbeBirrAdapter(
|
||||||
|
_a: number,
|
||||||
|
ref: string,
|
||||||
|
): Promise<GatewayResult> {
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
return { success: true, providerRef: `CBE-${ref}-${Date.now()}` };
|
||||||
|
}
|
||||||
|
export async function eBirrAdapter(
|
||||||
|
_a: number,
|
||||||
|
ref: string,
|
||||||
|
): Promise<GatewayResult> {
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
return { success: true, providerRef: `EB-${ref}-${Date.now()}` };
|
||||||
|
}
|
||||||
|
export async function cardAdapter(
|
||||||
|
_a: number,
|
||||||
|
ref: string,
|
||||||
|
): Promise<GatewayResult> {
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
return {
|
||||||
|
success: !ref.startsWith("FAIL"),
|
||||||
|
providerRef: `CARD-${ref}-${Date.now()}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function walletAdapter(
|
||||||
|
amount: number,
|
||||||
|
balance: number,
|
||||||
|
): Promise<GatewayResult> {
|
||||||
|
return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` };
|
||||||
}
|
}
|
||||||
export async function cbeBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; }
|
|
||||||
export async function eBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; }
|
|
||||||
export async function cardAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: !ref.startsWith('FAIL'), providerRef: `CARD-${ref}-${Date.now()}` }; }
|
|
||||||
export async function walletAdapter(amount: number, balance: number): Promise<GatewayResult> { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; }
|
|
||||||
|
|||||||
@@ -1,34 +1,59 @@
|
|||||||
import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
|
import {
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger';
|
Body,
|
||||||
import { Response } from 'express';
|
Controller,
|
||||||
import { PaymentsService } from './payments.service';
|
Get,
|
||||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto';
|
HttpStatus,
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
Param,
|
||||||
import { RolesGuard } from '../../common/roles.guard';
|
Post,
|
||||||
import { Roles } from '../../common/roles.decorator';
|
Query,
|
||||||
import { UserRole } from '@prisma/client';
|
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')
|
@ApiTags("Payment")
|
||||||
@Controller('payments')
|
@Controller("payments")
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(private service: PaymentsService) {}
|
constructor(private service: PaymentsService) {}
|
||||||
|
|
||||||
@Get('all')
|
@Get("all")
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
@UseGuards(JwtGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF)
|
@Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth("JWT-auth")
|
||||||
@ApiOperation({ summary: 'Get all payments with filters (staff/admin only)' })
|
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
||||||
@ApiQuery({ name: 'search', required: false })
|
@ApiQuery({ name: "search", required: false })
|
||||||
@ApiQuery({ name: 'status', required: false })
|
@ApiQuery({ name: "status", required: false })
|
||||||
@ApiQuery({ name: 'method', required: false })
|
@ApiQuery({ name: "method", required: false })
|
||||||
@ApiQuery({ name: 'page', required: false })
|
@ApiQuery({ name: "page", required: false })
|
||||||
@ApiQuery({ name: 'pageSize', required: false })
|
@ApiQuery({ name: "pageSize", required: false })
|
||||||
async getAll(
|
async getAll(
|
||||||
@Query('search') search?: string,
|
@Query("search") search?: string,
|
||||||
@Query('status') status?: string,
|
@Query("status") status?: string,
|
||||||
@Query('method') method?: string,
|
@Query("method") method?: string,
|
||||||
@Query('page') page?: string,
|
@Query("page") page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query("pageSize") pageSize?: string,
|
||||||
) {
|
) {
|
||||||
return this.service.getAll({
|
return this.service.getAll({
|
||||||
search,
|
search,
|
||||||
@@ -38,80 +63,121 @@ export class PaymentsController {
|
|||||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
pageSize: pageSize ? parseInt(pageSize) : 10,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('initiate')
|
@Post("initiate")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Initiate payment with nationality-based payment methods',
|
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`
|
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); }
|
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); }
|
@Get("intents/:bookingId")
|
||||||
|
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||||
@Post('refund')
|
getIntent(@Param("bookingId") bookingId: string) {
|
||||||
|
return this.service.getIntentByBookingId(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("refund")
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
@UseGuards(JwtGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
|
@Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth("JWT-auth")
|
||||||
@ApiOperation({ summary: 'Refund a confirmed booking (staff/agent only)' })
|
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
|
||||||
refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
|
refund(@Body() dto: RefundDto) {
|
||||||
|
return this.service.refund(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('methods')
|
@Post("methods")
|
||||||
@UseGuards(JwtGuard, RolesGuard)
|
@UseGuards(JwtGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.STAFF)
|
@Roles(UserRole.ADMIN, UserRole.STAFF)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@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')
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'List payment systems supported by the platform',
|
summary: "Add a payment system to the platform catalog (admin only)",
|
||||||
description: 'Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger\'s nationality.',
|
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false })
|
addMethod(@Body() dto: AddPaymentMethodDto) {
|
||||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
return this.service.addPaymentMethod(dto);
|
||||||
getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); }
|
}
|
||||||
|
|
||||||
@Get('checkout')
|
@Get("methods")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Browser checkout redirect',
|
summary: "List payment systems supported by the platform",
|
||||||
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.',
|
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: "region", enum: PaymentRegionEnum, required: false })
|
||||||
@ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true })
|
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||||
@ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false })
|
getMethods(@Query("region") region?: PaymentRegionEnum) {
|
||||||
@ApiProduces('text/html')
|
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(
|
async checkout(
|
||||||
@Query('bookingId') bookingId: string,
|
@Query("bookingId") bookingId: string,
|
||||||
@Query('method') method: PaymentMethodTypeEnum,
|
@Query("method") method: PaymentMethodTypeEnum,
|
||||||
@Query('platform') platform: PaymentPlatformDto = 'web',
|
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
if (!bookingId) {
|
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)) {
|
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 {
|
try {
|
||||||
const result = await this.service.initiatePayment({ bookingId, method, platform });
|
const result = await this.service.initiatePayment({
|
||||||
const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined;
|
bookingId,
|
||||||
|
method,
|
||||||
|
platform,
|
||||||
|
});
|
||||||
|
const url =
|
||||||
|
result.clientAction?.type === "REDIRECT"
|
||||||
|
? result.clientAction.url
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (url) {
|
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) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
|
const message =
|
||||||
return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(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 {
|
private buildRedirectHtml(url: string): string {
|
||||||
const escaped = url.replace(/\"/g, '"');
|
const escaped = url.replace(/\"/g, """);
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -1,36 +1,53 @@
|
|||||||
import { IsString, IsEnum, IsOptional, IsIn, IsBoolean, IsInt } from 'class-validator';
|
import {
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
IsString,
|
||||||
import { PaymentIntentStatus } from '@prisma/client';
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsIn,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
} from "class-validator";
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { PaymentIntentStatus } from "@prisma/client";
|
||||||
|
|
||||||
export enum PaymentRegionEnum {
|
export enum PaymentRegionEnum {
|
||||||
ETHIOPIA = 'ETHIOPIA',
|
ETHIOPIA = "ETHIOPIA",
|
||||||
DJIBOUTI = 'DJIBOUTI',
|
DJIBOUTI = "DJIBOUTI",
|
||||||
INTERNATIONAL = 'INTERNATIONAL',
|
INTERNATIONAL = "INTERNATIONAL",
|
||||||
GLOBAL = 'GLOBAL',
|
GLOBAL = "GLOBAL",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PaymentMethodTypeEnum {
|
export enum PaymentMethodTypeEnum {
|
||||||
TELEBIRR = 'TELEBIRR', // Ethiopia
|
TELEBIRR = "TELEBIRR", // Ethiopia
|
||||||
CBE_BIRR = 'CBE_BIRR', // Ethiopia
|
CBE_BIRR = "CBE_BIRR", // Ethiopia
|
||||||
EBIRR = 'EBIRR', // Ethiopia
|
EBIRR = "EBIRR", // Ethiopia
|
||||||
WAAFI = 'WAAFI', // Djibouti
|
WAAFI = "WAAFI", // Djibouti
|
||||||
CARD = 'CARD', // International
|
CARD = "CARD", // International
|
||||||
WALLET = 'WALLET' // Internal
|
WALLET = "WALLET", // Internal
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentPlatformDto = 'web' | 'mobile';
|
export type PaymentPlatformDto = "web" | "mobile";
|
||||||
|
|
||||||
export class InitiatePaymentDto {
|
export class InitiatePaymentDto {
|
||||||
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
|
@ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string;
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
enum: PaymentMethodTypeEnum,
|
enum: PaymentMethodTypeEnum,
|
||||||
description: 'Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)',
|
description:
|
||||||
example: 'TELEBIRR'
|
"Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)",
|
||||||
}) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
example: "TELEBIRR",
|
||||||
@ApiPropertyOptional({ description: 'Saved payment method ID (optional)' }) @IsOptional() @IsString() paymentMethodId?: string;
|
})
|
||||||
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web', description: 'Payment platform (web or mobile)' })
|
@IsEnum(PaymentMethodTypeEnum)
|
||||||
|
method: PaymentMethodTypeEnum;
|
||||||
|
@ApiPropertyOptional({ description: "Saved payment method ID (optional)" })
|
||||||
@IsOptional()
|
@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;
|
platform?: PaymentPlatformDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,42 +57,76 @@ export class RefundDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AddPaymentMethodDto {
|
export class AddPaymentMethodDto {
|
||||||
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum;
|
@ApiProperty({ enum: PaymentMethodTypeEnum })
|
||||||
|
@IsEnum(PaymentMethodTypeEnum)
|
||||||
|
type: PaymentMethodTypeEnum;
|
||||||
@ApiProperty() @IsString() displayName: string;
|
@ApiProperty() @IsString() displayName: string;
|
||||||
@ApiProperty({ enum: PaymentRegionEnum }) @IsEnum(PaymentRegionEnum) region: PaymentRegionEnum;
|
@ApiProperty({ enum: PaymentRegionEnum })
|
||||||
@ApiPropertyOptional({ example: 'ETB' }) @IsOptional() @IsString() currency?: string;
|
@IsEnum(PaymentRegionEnum)
|
||||||
|
region: PaymentRegionEnum;
|
||||||
|
@ApiPropertyOptional({ example: "ETB" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
currency?: string;
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() providerId?: string;
|
@ApiPropertyOptional() @IsOptional() @IsString() providerId?: string;
|
||||||
@ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() enabled?: boolean;
|
@ApiPropertyOptional({ default: true })
|
||||||
@ApiPropertyOptional({ default: 0 }) @IsOptional() @IsInt() sortOrder?: number;
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
@ApiPropertyOptional({ default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SupportedPaymentMethodDto {
|
export class SupportedPaymentMethodDto {
|
||||||
@ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum;
|
@ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum;
|
||||||
@ApiProperty({ example: 'Telebirr' }) displayName: string;
|
@ApiProperty({ example: "Telebirr" }) displayName: string;
|
||||||
@ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum;
|
@ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum;
|
||||||
@ApiProperty({ example: 'ETB', description: 'Settlement currency for this method' }) currency: string;
|
@ApiProperty({
|
||||||
@ApiProperty({ description: 'Whether the platform currently accepts this method' }) enabled: boolean;
|
example: "ETB",
|
||||||
|
description: "Settlement currency for this method",
|
||||||
|
})
|
||||||
|
currency: string;
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Whether the platform currently accepts this method",
|
||||||
|
})
|
||||||
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ClientActionDto {
|
export class ClientActionDto {
|
||||||
@ApiProperty({ enum: ['REDIRECT', 'LAUNCH_APP'] }) type: 'REDIRECT' | 'LAUNCH_APP';
|
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type:
|
||||||
@ApiPropertyOptional({ description: 'Set when type=REDIRECT (web flow)' }) url?: string;
|
| "REDIRECT"
|
||||||
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) prepayId?: string;
|
| "LAUNCH_APP";
|
||||||
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) receiveCode?: string;
|
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||||
@ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) shortCode?: string;
|
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 {
|
export class InitiateResponseDto {
|
||||||
@ApiProperty() intentId: string;
|
@ApiProperty() intentId: string;
|
||||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
@ApiPropertyOptional({ type: ClientActionDto })
|
||||||
|
clientAction?: ClientActionDto;
|
||||||
@ApiPropertyOptional() merchantOrderId?: string;
|
@ApiPropertyOptional() merchantOrderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IntentStatusDto {
|
export class IntentStatusDto {
|
||||||
@ApiProperty() intentId: string;
|
@ApiProperty() intentId: string;
|
||||||
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
@ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus;
|
||||||
@ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto;
|
@ApiPropertyOptional({ type: ClientActionDto })
|
||||||
|
clientAction?: ClientActionDto;
|
||||||
@ApiPropertyOptional() merchantOrderId?: string;
|
@ApiPropertyOptional() merchantOrderId?: string;
|
||||||
@ApiPropertyOptional() paidAt?: string;
|
@ApiPropertyOptional() paidAt?: string;
|
||||||
@ApiPropertyOptional() failureCode?: string;
|
@ApiPropertyOptional() failureCode?: string;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||||
import request from 'supertest';
|
import request from "supertest";
|
||||||
import { AppModule } from '../../app.module';
|
import { AppModule } from "../../app.module";
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from "../../common/prisma.service";
|
||||||
|
|
||||||
describe('Payments E2E', () => {
|
describe("Payments E2E", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let authToken: string;
|
let authToken: string;
|
||||||
@@ -16,47 +16,123 @@ describe('Payments E2E', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({ transform: true, whitelist: true }),
|
||||||
|
);
|
||||||
await app.init();
|
await app.init();
|
||||||
|
|
||||||
prisma = app.get<PrismaService>(PrismaService);
|
prisma = app.get<PrismaService>(PrismaService);
|
||||||
|
|
||||||
const testUser = await prisma.user.create({
|
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 station1 = await prisma.station.create({
|
||||||
const station2 = await prisma.station.create({ data: { code: 'TST2', name: 'Test Station 2', city: 'Test City 2', lat: 9.5, lng: 38.5 } });
|
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({
|
const schedule = await prisma.trainSchedule.create({
|
||||||
data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 },
|
data: {
|
||||||
|
trainId: train.id,
|
||||||
|
originStationId: station1.id,
|
||||||
|
destinationStationId: station2.id,
|
||||||
|
departureAt: new Date(Date.now() + 86400000),
|
||||||
|
arrivalAt: new Date(Date.now() + 90000000),
|
||||||
|
durationMinutes: 60,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } });
|
const coachType = await prisma.coachType.create({
|
||||||
|
data: { name: "Standard", code: "STD" },
|
||||||
|
});
|
||||||
|
|
||||||
const seatClass = await prisma.seatClass.create({
|
const seatClass = await prisma.seatClass.create({
|
||||||
data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id },
|
data: {
|
||||||
|
name: "Economy Regular",
|
||||||
|
description: "Standard economy seating",
|
||||||
|
baseFareMinor: 45000,
|
||||||
|
isActive: true,
|
||||||
|
coachTypeId: coachType.id,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const coach = await prisma.coach.create({
|
const coach = await prisma.coach.create({
|
||||||
data: { coachTypeId: coachType.id, number: 'TEST-C1', arrangement: '2+2', capacity: 10, status: 'ACTIVE' },
|
data: {
|
||||||
|
coachTypeId: coachType.id,
|
||||||
|
number: "TEST-C1",
|
||||||
|
arrangement: "2+2",
|
||||||
|
capacity: 10,
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } });
|
const seat = await prisma.seat.create({
|
||||||
|
data: {
|
||||||
|
coachId: coach.id,
|
||||||
|
row: 1,
|
||||||
|
col: "A",
|
||||||
|
seatNumber: "1A",
|
||||||
|
status: "AVAILABLE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const booking = await prisma.booking.create({
|
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;
|
bookingId = booking.id;
|
||||||
});
|
});
|
||||||
@@ -71,89 +147,60 @@ describe('Payments E2E', () => {
|
|||||||
prisma.coach.deleteMany(),
|
prisma.coach.deleteMany(),
|
||||||
prisma.trainSchedule.deleteMany(),
|
prisma.trainSchedule.deleteMany(),
|
||||||
prisma.train.deleteMany(),
|
prisma.train.deleteMany(),
|
||||||
prisma.station.deleteMany({ where: { code: { in: ['TST1', 'TST2'] } } }),
|
prisma.station.deleteMany({ where: { code: { in: ["TST1", "TST2"] } } }),
|
||||||
prisma.walletLedgerEntry.deleteMany(),
|
prisma.walletLedgerEntry.deleteMany(),
|
||||||
prisma.walletAccount.deleteMany(),
|
prisma.walletAccount.deleteMany(),
|
||||||
prisma.passenger.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();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /payments/initiate', () => {
|
describe("POST /payments/initiate", () => {
|
||||||
it('should initiate wallet payment successfully', async () => {
|
it("should initiate wallet payment successfully", async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.post('/payments/initiate')
|
.post("/payments/initiate")
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.send({ bookingId, method: 'WALLET' })
|
.send({ bookingId, method: "WALLET" })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect(response.body.intentId).toBeDefined();
|
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())
|
await request(app.getHttpServer())
|
||||||
.post('/payments/initiate')
|
.post("/payments/initiate")
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.send({ bookingId, method: 'INVALID_METHOD' })
|
.send({ bookingId, method: "INVALID_METHOD" })
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent booking', async () => {
|
it("should return 404 for non-existent booking", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post('/payments/initiate')
|
.post("/payments/initiate")
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.send({ bookingId: 'non-existent-id', method: 'WALLET' })
|
.send({ bookingId: "non-existent-id", method: "WALLET" })
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /payments/intents/:bookingId', () => {
|
describe("GET /payments/intents/:bookingId", () => {
|
||||||
it('should get payment intent status', async () => {
|
it("should get payment intent status", async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/payments/intents/${bookingId}`)
|
.get(`/payments/intents/${bookingId}`)
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(response.body.intentId).toBeDefined();
|
expect(response.body.intentId).toBeDefined();
|
||||||
expect(response.body.status).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())
|
await request(app.getHttpServer())
|
||||||
.get('/payments/intents/non-existent-booking')
|
.get("/payments/intents/non-existent-booking")
|
||||||
.set('Authorization', `Bearer ${authToken}`)
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Webhook endpoints', () => {
|
// Provider webhooks moved to the payment microservice (apps/edr-payment-api /webhooks/*).
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,25 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { PaymentsController } from './payments.controller';
|
import { PaymentsController } from "./payments.controller";
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from "./payments.service";
|
||||||
import { SeatsModule } from '../seats/seats.module';
|
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||||
import { TicketsModule } from '../tickets/tickets.module';
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
import {
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
TelebirrProvider,
|
import { SeatsModule } from "../seats/seats.module";
|
||||||
CbeBirrProvider,
|
import { TicketsModule } from "../tickets/tickets.module";
|
||||||
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';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-cutover (docs/payment-service phase 6): provider gateways and webhook handlers live in
|
||||||
|
* apps/edr-payment-api. This module keeps domain validation, the WALLET flow, the payment
|
||||||
|
* client, and the idempotent mark-paid consumer.
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })],
|
imports: [
|
||||||
controllers: [PaymentsController, WebhooksController],
|
SeatsModule,
|
||||||
providers: [
|
TicketsModule,
|
||||||
PaymentsService,
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
TelebirrProvider,
|
|
||||||
CbeBirrProvider,
|
|
||||||
EBirrProvider,
|
|
||||||
CardProvider,
|
|
||||||
WaafiProvider,
|
|
||||||
TelebirrWebhookService,
|
|
||||||
CbeBirrWebhookService,
|
|
||||||
EBirrWebhookService,
|
|
||||||
CardWebhookService,
|
|
||||||
WaafiWebhookService,
|
|
||||||
],
|
],
|
||||||
|
controllers: [PaymentsController, InternalPaymentsController],
|
||||||
|
providers: [PaymentsService, PaymentClientService, ServiceAuthGuard],
|
||||||
})
|
})
|
||||||
export class PaymentsModule {}
|
export class PaymentsModule {}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from "./payments.service";
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
import { SeatsService } from '../seats/seats.service';
|
import { PrismaService } from "../../common/prisma.service";
|
||||||
import { TicketsService } from '../tickets/tickets.service';
|
import { SeatsService } from "../seats/seats.service";
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
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 {
|
import {
|
||||||
TelebirrProvider,
|
PaymentIntentSnapshot,
|
||||||
CbeBirrProvider,
|
PaymentReferenceType,
|
||||||
EBirrProvider,
|
PaymentService as PaymentServiceEnum,
|
||||||
CardProvider,
|
ProviderMethod,
|
||||||
} from '@edr/payment-providers';
|
ProviderPaymentStatus,
|
||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
} from "@edr/types";
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
||||||
|
|
||||||
describe('PaymentsService', () => {
|
describe("PaymentsService", () => {
|
||||||
let service: PaymentsService;
|
let service: PaymentsService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let seatsService: SeatsService;
|
let seatsService: SeatsService;
|
||||||
@@ -62,29 +64,25 @@ describe('PaymentsService', () => {
|
|||||||
emit: jest.fn(),
|
emit: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockTelebirrProvider = {
|
const mockPaymentClient = {
|
||||||
method: PaymentMethodType.TELEBIRR,
|
|
||||||
initiate: jest.fn(),
|
initiate: jest.fn(),
|
||||||
queryStatus: jest.fn(),
|
getIntentByReference: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockCbeBirrProvider = {
|
const requiresActionSnapshot = (
|
||||||
method: PaymentMethodType.CBE_BIRR,
|
provider: ProviderMethod,
|
||||||
initiate: jest.fn(),
|
): PaymentIntentSnapshot => ({
|
||||||
queryStatus: jest.fn(),
|
intentId: "remote-intent-1",
|
||||||
};
|
service: PaymentServiceEnum.PASSENGER,
|
||||||
|
referenceType: PaymentReferenceType.BOOKING,
|
||||||
const mockEBirrProvider = {
|
referenceId: "booking-1",
|
||||||
method: PaymentMethodType.EBIRR,
|
merchantOrderId: "PSG-MERCH-123",
|
||||||
initiate: jest.fn(),
|
provider,
|
||||||
queryStatus: jest.fn(),
|
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
};
|
amountMinor: 50000,
|
||||||
|
currency: "ETB",
|
||||||
const mockCardProvider = {
|
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||||
method: PaymentMethodType.CARD,
|
});
|
||||||
initiate: jest.fn(),
|
|
||||||
queryStatus: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -94,10 +92,7 @@ describe('PaymentsService', () => {
|
|||||||
{ provide: SeatsService, useValue: mockSeatsService },
|
{ provide: SeatsService, useValue: mockSeatsService },
|
||||||
{ provide: TicketsService, useValue: mockTicketsService },
|
{ provide: TicketsService, useValue: mockTicketsService },
|
||||||
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
||||||
{ provide: TelebirrProvider, useValue: mockTelebirrProvider },
|
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
||||||
{ provide: CbeBirrProvider, useValue: mockCbeBirrProvider },
|
|
||||||
{ provide: EBirrProvider, useValue: mockEBirrProvider },
|
|
||||||
{ provide: CardProvider, useValue: mockCardProvider },
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -108,221 +103,276 @@ describe('PaymentsService', () => {
|
|||||||
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
||||||
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
mockPaymentClient.getIntentByReference.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initiatePayment', () => {
|
describe("initiatePayment", () => {
|
||||||
const mockBooking = {
|
const mockBooking = {
|
||||||
id: 'booking-1',
|
id: "booking-1",
|
||||||
bookingRef: 'EDR123456',
|
bookingRef: "EDR123456",
|
||||||
passengerId: 'passenger-1',
|
passengerId: "passenger-1",
|
||||||
totalMinor: 50000,
|
totalMinor: 50000,
|
||||||
currency: 'ETB',
|
currency: "ETB",
|
||||||
status: 'PENDING_PAYMENT',
|
status: "PENDING_PAYMENT",
|
||||||
seats: [{ id: 'seat-1', seatId: 'seat-id-1' }],
|
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);
|
mockPrisma.booking.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.initiatePayment({
|
service.initiatePayment({
|
||||||
bookingId: 'invalid',
|
bookingId: "invalid",
|
||||||
method: 'TELEBIRR' as any,
|
method: "TELEBIRR" as any,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(NotFoundException);
|
).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({
|
mockPrisma.booking.findUnique.mockResolvedValue({
|
||||||
...mockBooking,
|
...mockBooking,
|
||||||
status: 'CONFIRMED',
|
status: "CONFIRMED",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.initiatePayment({
|
service.initiatePayment({
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
method: 'TELEBIRR' as any,
|
method: "TELEBIRR" as any,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(BadRequestException);
|
).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.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
mockPaymentClient.initiate.mockResolvedValue(
|
||||||
mockTelebirrProvider.initiate.mockResolvedValue({
|
requiresActionSnapshot(ProviderMethod.TELEBIRR),
|
||||||
providerOrderId: 'TB-ORDER-123',
|
);
|
||||||
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
|
|
||||||
expiresAt: new Date(),
|
|
||||||
rawInitiation: {},
|
|
||||||
});
|
|
||||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||||
merchantOrderId: 'MERCH-123',
|
merchantOrderId: "PSG-MERCH-123",
|
||||||
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
|
clientAction: { type: "REDIRECT", url: "https://provider.example/pay" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.initiatePayment({
|
const result = await service.initiatePayment({
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
method: 'TELEBIRR' as any,
|
method: "TELEBIRR" as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
|
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.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
mockPaymentClient.initiate.mockResolvedValue({
|
||||||
mockCbeBirrProvider.initiate.mockResolvedValue({
|
...requiresActionSnapshot(ProviderMethod.WAAFI),
|
||||||
providerOrderId: 'CBE-ORDER-123',
|
status: ProviderPaymentStatus.SUCCEEDED,
|
||||||
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
|
providerTxnId: "TXN-1",
|
||||||
expiresAt: new Date(),
|
paidAt: new Date().toISOString(),
|
||||||
rawInitiation: {},
|
|
||||||
});
|
});
|
||||||
|
// Projection clamps SUCCEEDED to PROCESSING; finalizePaymentSuccess flips it.
|
||||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
bookingId: "booking-1",
|
||||||
merchantOrderId: 'MERCH-123',
|
status: PaymentIntentStatus.PROCESSING,
|
||||||
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
|
|
||||||
});
|
});
|
||||||
|
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
|
||||||
const result = await service.initiatePayment({
|
id: "intent-1",
|
||||||
bookingId: 'booking-1',
|
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',
|
|
||||||
status: PaymentIntentStatus.PROCESSING,
|
status: PaymentIntentStatus.PROCESSING,
|
||||||
});
|
});
|
||||||
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.SUCCEEDED,
|
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({
|
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
||||||
id: 'loyalty-1',
|
id: "loyalty-1",
|
||||||
pointsBalance: 100,
|
pointsBalance: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.initiatePayment({
|
const result = await service.initiatePayment({
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
method: 'WALLET' as any,
|
method: "WALLET" as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
|
||||||
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
|
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
|
||||||
expect(mockTicketsService.generate).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.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
|
||||||
mockPrisma.walletAccount.findUnique.mockResolvedValue({
|
mockPrisma.walletAccount.findUnique.mockResolvedValue({
|
||||||
id: 'wallet-1',
|
id: "wallet-1",
|
||||||
passengerId: 'passenger-1',
|
passengerId: "passenger-1",
|
||||||
balanceMinor: 10000, // Less than booking total
|
balanceMinor: 10000, // Less than booking total
|
||||||
});
|
});
|
||||||
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
mockPrisma.paymentIntent.upsert.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.FAILED,
|
status: PaymentIntentStatus.FAILED,
|
||||||
failureCode: 'INSUFFICIENT_BALANCE',
|
failureCode: "INSUFFICIENT_BALANCE",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.initiatePayment({
|
const result = await service.initiatePayment({
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
method: 'WALLET' as any,
|
method: "WALLET" as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe(PaymentIntentStatus.FAILED);
|
expect(result.status).toBe(PaymentIntentStatus.FAILED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('finalizePaymentSuccess', () => {
|
describe("finalizePaymentSuccess", () => {
|
||||||
it('should finalize payment and issue ticket', async () => {
|
it("should finalize payment and issue ticket", async () => {
|
||||||
const mockIntent = {
|
const mockIntent = {
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
status: PaymentIntentStatus.PROCESSING,
|
status: PaymentIntentStatus.PROCESSING,
|
||||||
};
|
};
|
||||||
const mockBooking = {
|
const mockBooking = {
|
||||||
id: 'booking-1',
|
id: "booking-1",
|
||||||
passengerId: 'passenger-1',
|
passengerId: "passenger-1",
|
||||||
totalMinor: 50000,
|
totalMinor: 50000,
|
||||||
seats: [{ seatId: 'seat-1' }],
|
seats: [{ seatId: "seat-1" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
||||||
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
|
||||||
id: 'loyalty-1',
|
id: "loyalty-1",
|
||||||
pointsBalance: 100,
|
pointsBalance: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.finalizePaymentSuccess({
|
const result = await service.finalizePaymentSuccess({
|
||||||
intentId: 'intent-1',
|
intentId: "intent-1",
|
||||||
providerTxnId: 'TXN-123',
|
providerTxnId: "TXN-123",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.alreadyFinalized).toBe(false);
|
expect(result.alreadyFinalized).toBe(false);
|
||||||
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']);
|
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(["seat-1"]);
|
||||||
expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1');
|
expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1");
|
||||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', {
|
expect(mockEventEmitter.emit).toHaveBeenCalledWith("payment.succeeded", {
|
||||||
booking: mockBooking,
|
booking: mockBooking,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return alreadyFinalized if payment already succeeded', async () => {
|
it("should return alreadyFinalized if payment already succeeded", async () => {
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
|
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
status: PaymentIntentStatus.SUCCEEDED,
|
status: PaymentIntentStatus.SUCCEEDED,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.finalizePaymentSuccess({
|
const result = await service.finalizePaymentSuccess({
|
||||||
intentId: 'intent-1',
|
intentId: "intent-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.alreadyFinalized).toBe(true);
|
expect(result.alreadyFinalized).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getIntentByBookingId', () => {
|
describe("getIntentByBookingId", () => {
|
||||||
it('should return intent status', async () => {
|
it("should return the cached local intent when the payment service has none", async () => {
|
||||||
const mockIntent = {
|
const mockIntent = {
|
||||||
id: 'intent-1',
|
id: "intent-1",
|
||||||
bookingId: 'booking-1',
|
bookingId: "booking-1",
|
||||||
status: PaymentIntentStatus.SUCCEEDED,
|
status: PaymentIntentStatus.SUCCEEDED,
|
||||||
method: PaymentMethodType.TELEBIRR,
|
method: PaymentMethodType.TELEBIRR,
|
||||||
paidAt: new Date(),
|
paidAt: new Date(),
|
||||||
merchantOrderId: 'MERCH-123',
|
merchantOrderId: "MERCH-123",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
|
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);
|
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);
|
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,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
|
||||||
import { SeatsService } from '../seats/seats.service';
|
|
||||||
import { TicketsService } from '../tickets/tickets.service';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client';
|
|
||||||
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto';
|
|
||||||
import {
|
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,
|
ClientAction,
|
||||||
PaymentProvider,
|
|
||||||
ProviderStatus,
|
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
TelebirrProvider,
|
} from "@edr/types";
|
||||||
CbeBirrProvider,
|
|
||||||
EBirrProvider,
|
|
||||||
CardProvider,
|
|
||||||
WaafiProvider,
|
|
||||||
createMerchantOrderId,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
|
|
||||||
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
||||||
PaymentIntentStatus.REQUIRES_ACTION,
|
PaymentIntentStatus.REQUIRES_ACTION,
|
||||||
@@ -27,37 +42,30 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
private readonly logger = new Logger(PaymentsService.name);
|
private readonly logger = new Logger(PaymentsService.name);
|
||||||
private readonly providers: Map<PaymentMethodType, PaymentProvider>;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private seatsService: SeatsService,
|
private seatsService: SeatsService,
|
||||||
private ticketsService: TicketsService,
|
private ticketsService: TicketsService,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
private telebirrProvider: TelebirrProvider,
|
private paymentClient: PaymentClientService,
|
||||||
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],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAll(filters: { search?: string; status?: string; method?: string; page?: number; pageSize?: number }) {
|
async getAll(filters: {
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
method?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}) {
|
||||||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ id: { contains: search, mode: 'insensitive' } },
|
{ id: { contains: search, mode: "insensitive" } },
|
||||||
{ booking: { bookingRef: { contains: search, mode: 'insensitive' } } },
|
{ booking: { bookingRef: { contains: search, mode: "insensitive" } } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
@@ -73,13 +81,13 @@ export class PaymentsService {
|
|||||||
include: { booking: true },
|
include: { booking: true },
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: "desc" },
|
||||||
}),
|
}),
|
||||||
this.prisma.paymentIntent.count({ where }),
|
this.prisma.paymentIntent.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map(item => ({
|
items: items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
reference: item.id.substring(0, 8),
|
reference: item.id.substring(0, 8),
|
||||||
bookingId: item.bookingId,
|
bookingId: item.bookingId,
|
||||||
@@ -102,30 +110,87 @@ export class PaymentsService {
|
|||||||
where: { id: dto.bookingId },
|
where: { id: dto.bookingId },
|
||||||
include: { seats: true },
|
include: { seats: true },
|
||||||
});
|
});
|
||||||
if (!booking) throw new NotFoundException('Booking not found');
|
if (!booking) throw new NotFoundException("Booking not found");
|
||||||
if (booking.status !== 'PENDING_PAYMENT') {
|
if (booking.status !== "PENDING_PAYMENT") {
|
||||||
throw new BadRequestException('Booking not payable');
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = dto.method as PaymentMethodType;
|
const method = dto.method as PaymentMethodType;
|
||||||
|
|
||||||
|
// WALLET is an internal balance debit — it never leaves this app.
|
||||||
if (method === PaymentMethodType.WALLET) {
|
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);
|
return this.initiateWalletPayment(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = this.providers.get(method);
|
// Provider methods go through the payment microservice (docs/payment-service §7.1):
|
||||||
if (provider) {
|
// it owns the intent, the provider session, and the single webhook per provider.
|
||||||
return this.initiateProviderPayment(booking, provider, dto.platform);
|
// 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(
|
private async initiateWalletPayment(
|
||||||
@@ -146,7 +211,7 @@ export class PaymentsService {
|
|||||||
await tx.walletLedgerEntry.create({
|
await tx.walletLedgerEntry.create({
|
||||||
data: {
|
data: {
|
||||||
walletId: wallet.id,
|
walletId: wallet.id,
|
||||||
type: 'DEBIT',
|
type: "DEBIT",
|
||||||
amountMinor: booking.totalMinor,
|
amountMinor: booking.totalMinor,
|
||||||
balanceAfterMinor: newBalance,
|
balanceAfterMinor: newBalance,
|
||||||
description: `Train Ticket - ${booking.bookingRef}`,
|
description: `Train Ticket - ${booking.bookingRef}`,
|
||||||
@@ -161,14 +226,14 @@ export class PaymentsService {
|
|||||||
where: { bookingId: booking.id },
|
where: { bookingId: booking.id },
|
||||||
update: {
|
update: {
|
||||||
status: PaymentIntentStatus.FAILED,
|
status: PaymentIntentStatus.FAILED,
|
||||||
failureCode: 'INSUFFICIENT_BALANCE',
|
failureCode: "INSUFFICIENT_BALANCE",
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
amountMinor: booking.totalMinor,
|
amountMinor: booking.totalMinor,
|
||||||
method: PaymentMethodType.WALLET,
|
method: PaymentMethodType.WALLET,
|
||||||
status: PaymentIntentStatus.FAILED,
|
status: PaymentIntentStatus.FAILED,
|
||||||
failureCode: 'INSUFFICIENT_BALANCE',
|
failureCode: "INSUFFICIENT_BALANCE",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.formatIntentResponse(failed);
|
return this.formatIntentResponse(failed);
|
||||||
@@ -192,55 +257,11 @@ export class PaymentsService {
|
|||||||
return this.formatIntentResponse(refreshed);
|
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(
|
private formatIntentResponse(
|
||||||
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
|
||||||
): InitiateResponseDto {
|
): InitiateResponseDto {
|
||||||
const clientAction =
|
const clientAction =
|
||||||
intent.clientAction && typeof intent.clientAction === 'object'
|
intent.clientAction && typeof intent.clientAction === "object"
|
||||||
? (intent.clientAction as unknown as ClientAction)
|
? (intent.clientAction as unknown as ClientAction)
|
||||||
: undefined;
|
: undefined;
|
||||||
return {
|
return {
|
||||||
@@ -252,65 +273,52 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
const local = await this.prisma.paymentIntent.findUnique({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
});
|
});
|
||||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
|
||||||
|
|
||||||
const refreshable =
|
// WALLET payments never leave this app — no remote intent exists for them.
|
||||||
intent.status === PaymentIntentStatus.REQUIRES_ACTION ||
|
if (local?.method === PaymentMethodType.WALLET) {
|
||||||
intent.status === PaymentIntentStatus.PROCESSING;
|
return this.formatIntentStatus(local);
|
||||||
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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
if (!snapshot) {
|
||||||
intentId: string,
|
// Pre-cutover/local-only intent (or service briefly unreachable): serve the cached
|
||||||
status: ProviderStatus,
|
// status. The payment service owns provider refresh for everything initiated after
|
||||||
): Promise<void> {
|
// the cutover; webhooks/mark-paid converge the rest.
|
||||||
const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } })
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||||
?.biz_content;
|
return this.formatIntentStatus(local);
|
||||||
if (bizContent?.order_status === 'PAY_SUCCESS') {
|
}
|
||||||
|
|
||||||
|
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({
|
await this.finalizePaymentSuccess({
|
||||||
intentId,
|
intentId: intent.id,
|
||||||
providerTxnId: status.providerTxnId,
|
providerTxnId: snapshot.providerTxnId,
|
||||||
|
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||||
});
|
});
|
||||||
return;
|
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
|
||||||
}
|
where: { id: intent.id },
|
||||||
if (status.status === ProviderPaymentStatus.FAILED) {
|
|
||||||
await this.markPaymentFailed({
|
|
||||||
intentId,
|
|
||||||
failureCode: status.failureCode,
|
|
||||||
failureMessage: status.failureMessage,
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
await this.prisma.paymentIntent.update({
|
return this.formatIntentStatus(intent);
|
||||||
where: { id: intentId },
|
|
||||||
data: {
|
|
||||||
status: status.status as unknown as PaymentIntentStatus,
|
|
||||||
providerTxnId: status.providerTxnId ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatIntentStatus(
|
private formatIntentStatus(
|
||||||
@@ -326,13 +334,25 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refund(dto: RefundDto) {
|
async refund(dto: RefundDto) {
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } });
|
const intent = await this.prisma.paymentIntent.findUnique({
|
||||||
if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund');
|
where: { bookingId: dto.bookingId },
|
||||||
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 (!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) {
|
if (booking) {
|
||||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
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 };
|
return { refunded: true, bookingRef: booking?.bookingRef };
|
||||||
}
|
}
|
||||||
@@ -342,7 +362,7 @@ export class PaymentsService {
|
|||||||
type: dto.type as unknown as PaymentMethodType,
|
type: dto.type as unknown as PaymentMethodType,
|
||||||
displayName: dto.displayName,
|
displayName: dto.displayName,
|
||||||
region: dto.region as unknown as PaymentRegion,
|
region: dto.region as unknown as PaymentRegion,
|
||||||
currency: dto.currency ?? 'ETB',
|
currency: dto.currency ?? "ETB",
|
||||||
providerId: dto.providerId,
|
providerId: dto.providerId,
|
||||||
enabled: dto.enabled ?? true,
|
enabled: dto.enabled ?? true,
|
||||||
sortOrder: dto.sortOrder ?? 0,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
@@ -359,10 +379,17 @@ export class PaymentsService {
|
|||||||
where: {
|
where: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...(region
|
...(region
|
||||||
? { region: { in: [region, PaymentRegionEnum.GLOBAL] as unknown as PaymentRegion[] } }
|
? {
|
||||||
|
region: {
|
||||||
|
in: [
|
||||||
|
region,
|
||||||
|
PaymentRegionEnum.GLOBAL,
|
||||||
|
] as unknown as PaymentRegion[],
|
||||||
|
},
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
|
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,19 +401,21 @@ export class PaymentsService {
|
|||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
const intent = await this.prisma.paymentIntent.findUnique({
|
||||||
where: { id: input.intentId },
|
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) {
|
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
||||||
return { alreadyFinalized: true };
|
return { alreadyFinalized: true };
|
||||||
}
|
}
|
||||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
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({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
where: { id: intent.bookingId },
|
where: { id: intent.bookingId },
|
||||||
include: { seats: true },
|
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();
|
const paidAt = input.paidAt ?? new Date();
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -394,45 +423,134 @@ export class PaymentsService {
|
|||||||
where: { id: intent.id },
|
where: { id: intent.id },
|
||||||
data: {
|
data: {
|
||||||
status: PaymentIntentStatus.SUCCEEDED,
|
status: PaymentIntentStatus.SUCCEEDED,
|
||||||
providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
providerTxnId:
|
||||||
|
input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||||
paidAt,
|
paidAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.booking.update({
|
await tx.booking.update({
|
||||||
where: { id: booking.id },
|
where: { id: booking.id },
|
||||||
data: { status: 'CONFIRMED' },
|
data: { status: "CONFIRMED" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`);
|
this.logger.error(
|
||||||
|
`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.createJourneySegments(booking);
|
await this.createJourneySegments(booking);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`);
|
this.logger.error(
|
||||||
|
`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.ticketsService.generate(booking.id);
|
await this.ticketsService.generate(booking.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`);
|
this.logger.error(
|
||||||
|
`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
|
await this.awardLoyaltyPoints(
|
||||||
|
booking.passengerId,
|
||||||
|
booking.totalMinor,
|
||||||
|
booking.id,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`);
|
this.logger.warn(
|
||||||
|
`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.eventEmitter.emit('payment.succeeded', { booking });
|
this.eventEmitter.emit("payment.succeeded", { booking });
|
||||||
return { alreadyFinalized: false };
|
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: {
|
async markPaymentFailed(input: {
|
||||||
intentId: string;
|
intentId: string;
|
||||||
failureCode?: string;
|
failureCode?: string;
|
||||||
@@ -441,7 +559,7 @@ export class PaymentsService {
|
|||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
const intent = await this.prisma.paymentIntent.findUnique({
|
||||||
where: { id: input.intentId },
|
where: { id: input.intentId },
|
||||||
});
|
});
|
||||||
if (!intent) throw new NotFoundException('PaymentIntent not found');
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||||
if (
|
if (
|
||||||
intent.status === PaymentIntentStatus.SUCCEEDED ||
|
intent.status === PaymentIntentStatus.SUCCEEDED ||
|
||||||
intent.status === PaymentIntentStatus.CANCELLED
|
intent.status === PaymentIntentStatus.CANCELLED
|
||||||
@@ -458,35 +576,72 @@ export class PaymentsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) {
|
private async awardLoyaltyPoints(
|
||||||
|
passengerId: string,
|
||||||
|
amountMinor: number,
|
||||||
|
bookingId: string,
|
||||||
|
) {
|
||||||
const points = Math.floor(amountMinor / 100);
|
const 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;
|
if (!account) return;
|
||||||
const newBalance = account.pointsBalance + points;
|
const newBalance = account.pointsBalance + points;
|
||||||
const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE';
|
const tier =
|
||||||
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
|
newBalance >= 10000
|
||||||
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
|
? "PLATINUM"
|
||||||
|
: newBalance >= 5000
|
||||||
|
? "GOLD"
|
||||||
|
: newBalance >= 2000
|
||||||
|
? "SILVER"
|
||||||
|
: "BRONZE";
|
||||||
|
await this.prisma.loyaltyAccount.update({
|
||||||
|
where: { passengerId },
|
||||||
|
data: { pointsBalance: { increment: points }, tier: tier as any },
|
||||||
|
});
|
||||||
|
await this.prisma.loyaltyLedgerEntry.create({
|
||||||
|
data: {
|
||||||
|
accountId: account.id,
|
||||||
|
delta: points,
|
||||||
|
reason: "TRIP_COMPLETED",
|
||||||
|
bookingId,
|
||||||
|
balanceAfter: newBalance,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) {
|
private async createJourneySegments(
|
||||||
|
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||||
|
) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: booking.scheduleId },
|
where: { id: booking.scheduleId },
|
||||||
include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
include: {
|
||||||
|
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!schedule) return;
|
if (!schedule) return;
|
||||||
|
|
||||||
const stopTimes = schedule.stopTimes;
|
const stopTimes = schedule.stopTimes;
|
||||||
if (stopTimes.length < 2) return;
|
if (stopTimes.length < 2) return;
|
||||||
|
|
||||||
const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId);
|
const originSequence = stopTimes.findIndex(
|
||||||
const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId);
|
(st) => st.stationId === schedule.originStationId,
|
||||||
|
);
|
||||||
|
const destSequence = stopTimes.findIndex(
|
||||||
|
(st) => st.stationId === schedule.destinationStationId,
|
||||||
|
);
|
||||||
|
|
||||||
if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return;
|
if (
|
||||||
|
originSequence < 0 ||
|
||||||
|
destSequence < 0 ||
|
||||||
|
originSequence >= destSequence
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
const journey = await this.prisma.journey.create({
|
const journey = await this.prisma.journey.create({
|
||||||
data: {
|
data: {
|
||||||
passengerId: booking.passengerId,
|
passengerId: booking.passengerId,
|
||||||
status: 'CONFIRMED',
|
status: "CONFIRMED",
|
||||||
totalMinor: booking.totalMinor,
|
totalMinor: booking.totalMinor,
|
||||||
currency: booking.currency,
|
currency: booking.currency,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers).
|
// The payment provider contract lives in @edr/types; the gateways themselves now run only
|
||||||
// This file remains as a thin re-export so existing local imports keep working.
|
// inside apps/edr-payment-api. This file remains as a thin re-export so existing local
|
||||||
|
// imports keep working.
|
||||||
export type {
|
export type {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -7,5 +8,5 @@ export type {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ClientAction,
|
ClientAction,
|
||||||
PaymentPlatform,
|
PaymentPlatform,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
export { ProviderPaymentStatus, ProviderMethod } from '@edr/types';
|
export { ProviderPaymentStatus, ProviderMethod } from "@edr/types";
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import {
|
|
||||||
CardProvider,
|
|
||||||
CardWebhookPayload,
|
|
||||||
ProviderPaymentStatus,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
|
||||||
import { PaymentsService } from '../payments.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CardWebhookService {
|
|
||||||
private readonly logger = new Logger(CardWebhookService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly provider: CardProvider,
|
|
||||||
private readonly payments: PaymentsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
|
|
||||||
const merchantOrderId = payload.data.object.metadata.merchantOrderId;
|
|
||||||
const externalEventId = `${payload.id}_${payload.type}`;
|
|
||||||
const signatureValid = this.provider.verifyWebhookSignature(
|
|
||||||
payload as unknown as Record<string, unknown>,
|
|
||||||
signature,
|
|
||||||
);
|
|
||||||
|
|
||||||
const eventRow = await this.persistEvent({
|
|
||||||
externalEventId,
|
|
||||||
merchantOrderId,
|
|
||||||
providerTxnId: payload.data.object.transaction_id,
|
|
||||||
signatureValid,
|
|
||||||
status: payload.data.object.status,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!eventRow) {
|
|
||||||
this.logger.log(`Card webhook duplicate: ${externalEventId} — short-circuit OK`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signatureValid) {
|
|
||||||
this.logger.warn(`Card webhook signature invalid for merchantOrderId=${merchantOrderId}`);
|
|
||||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
|
||||||
where: { merchantOrderId },
|
|
||||||
});
|
|
||||||
if (!intent) {
|
|
||||||
this.logger.warn(`Card webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
|
|
||||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapped = this.provider.mapWebhookStatus(payload.data.object.status);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
|
||||||
await this.payments.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
|
||||||
providerTxnId: payload.data.object.transaction_id,
|
|
||||||
paidAt: payload.data.object.paid_at ? new Date(payload.data.object.paid_at * 1000) : undefined,
|
|
||||||
});
|
|
||||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
|
||||||
await this.payments.markPaymentFailed({
|
|
||||||
intentId: intent.id,
|
|
||||||
failureCode: payload.data.object.failure_code,
|
|
||||||
failureMessage: payload.data.object.failure_message,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this.prisma.paymentIntent.update({
|
|
||||||
where: { id: intent.id },
|
|
||||||
data: {
|
|
||||||
status: mapped as unknown as PaymentIntentStatus,
|
|
||||||
providerTxnId: payload.data.object.transaction_id ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await this.markProcessed(eventRow.id);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`Card webhook processing failed for ${merchantOrderId}: ${message}`);
|
|
||||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistEvent(input: {
|
|
||||||
externalEventId: string;
|
|
||||||
merchantOrderId: string;
|
|
||||||
providerTxnId?: string;
|
|
||||||
signatureValid: boolean;
|
|
||||||
status: string;
|
|
||||||
payload: CardWebhookPayload;
|
|
||||||
}): Promise<{ id: string } | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.paymentWebhookEvent.create({
|
|
||||||
data: {
|
|
||||||
provider: PaymentMethodType.CARD,
|
|
||||||
externalEventId: input.externalEventId,
|
|
||||||
merchantOrderId: input.merchantOrderId,
|
|
||||||
providerTxnId: input.providerTxnId,
|
|
||||||
signatureValid: input.signatureValid,
|
|
||||||
status: input.status,
|
|
||||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (
|
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
err.code === 'P2002'
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
|
||||||
await this.prisma.paymentWebhookEvent.update({
|
|
||||||
where: { id: eventId },
|
|
||||||
data: { processedAt: new Date(), processingError },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import {
|
|
||||||
CbeBirrProvider,
|
|
||||||
CbeBirrWebhookPayload,
|
|
||||||
ProviderPaymentStatus,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
|
||||||
import { PaymentsService } from '../payments.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CbeBirrWebhookService {
|
|
||||||
private readonly logger = new Logger(CbeBirrWebhookService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly provider: CbeBirrProvider,
|
|
||||||
private readonly payments: PaymentsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
|
|
||||||
const merchantOrderId = payload.merchantOrderId;
|
|
||||||
const externalEventId = `${payload.orderId}_${payload.status}`;
|
|
||||||
const signatureValid = this.provider.verifyWebhookSignature(
|
|
||||||
payload as unknown as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const eventRow = await this.persistEvent({
|
|
||||||
externalEventId,
|
|
||||||
merchantOrderId,
|
|
||||||
providerTxnId: payload.transactionId ?? payload.orderId,
|
|
||||||
signatureValid,
|
|
||||||
status: payload.status,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!eventRow) {
|
|
||||||
this.logger.log(`CBE Birr webhook duplicate: ${externalEventId} — short-circuit OK`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signatureValid) {
|
|
||||||
this.logger.warn(`CBE Birr webhook signature invalid for merchantOrderId=${merchantOrderId}`);
|
|
||||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
|
||||||
where: { merchantOrderId },
|
|
||||||
});
|
|
||||||
if (!intent) {
|
|
||||||
this.logger.warn(`CBE Birr webhook: no PaymentIntent for merchantOrderId=${merchantOrderId}`);
|
|
||||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapped = this.provider.mapWebhookStatus(payload.status);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
|
||||||
await this.payments.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
|
||||||
providerTxnId: payload.transactionId ?? payload.orderId,
|
|
||||||
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
|
|
||||||
});
|
|
||||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
|
||||||
await this.payments.markPaymentFailed({
|
|
||||||
intentId: intent.id,
|
|
||||||
failureCode: payload.status,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this.prisma.paymentIntent.update({
|
|
||||||
where: { id: intent.id },
|
|
||||||
data: {
|
|
||||||
status: mapped as unknown as PaymentIntentStatus,
|
|
||||||
providerTxnId: payload.transactionId ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await this.markProcessed(eventRow.id);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`CBE Birr webhook processing failed for ${merchantOrderId}: ${message}`);
|
|
||||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistEvent(input: {
|
|
||||||
externalEventId: string;
|
|
||||||
merchantOrderId: string;
|
|
||||||
providerTxnId?: string;
|
|
||||||
signatureValid: boolean;
|
|
||||||
status: string;
|
|
||||||
payload: CbeBirrWebhookPayload;
|
|
||||||
}): Promise<{ id: string } | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.paymentWebhookEvent.create({
|
|
||||||
data: {
|
|
||||||
provider: PaymentMethodType.CBE_BIRR,
|
|
||||||
externalEventId: input.externalEventId,
|
|
||||||
merchantOrderId: input.merchantOrderId,
|
|
||||||
providerTxnId: input.providerTxnId,
|
|
||||||
signatureValid: input.signatureValid,
|
|
||||||
status: input.status,
|
|
||||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (
|
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
err.code === 'P2002'
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
|
||||||
await this.prisma.paymentWebhookEvent.update({
|
|
||||||
where: { id: eventId },
|
|
||||||
data: { processedAt: new Date(), processingError },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import {
|
|
||||||
EBirrProvider,
|
|
||||||
EBirrWebhookPayload,
|
|
||||||
ProviderPaymentStatus,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
|
||||||
import { PaymentsService } from '../payments.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EBirrWebhookService {
|
|
||||||
private readonly logger = new Logger(EBirrWebhookService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly provider: EBirrProvider,
|
|
||||||
private readonly payments: PaymentsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handle(payload: EBirrWebhookPayload): Promise<void> {
|
|
||||||
const merchantOrderId = payload.orderNo;
|
|
||||||
const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`;
|
|
||||||
const signatureValid = this.provider.verifyWebhookSignature(
|
|
||||||
payload as unknown as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const eventRow = await this.persistEvent({
|
|
||||||
externalEventId,
|
|
||||||
merchantOrderId,
|
|
||||||
providerTxnId: payload.tradeNo,
|
|
||||||
signatureValid,
|
|
||||||
status: payload.tradeStatus,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!eventRow) {
|
|
||||||
this.logger.log(`eBirr webhook duplicate: ${externalEventId} — short-circuit OK`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signatureValid) {
|
|
||||||
this.logger.warn(`eBirr webhook signature invalid for orderNo=${merchantOrderId}`);
|
|
||||||
await this.markProcessed(eventRow.id, 'signature-invalid');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
|
||||||
where: { merchantOrderId },
|
|
||||||
});
|
|
||||||
if (!intent) {
|
|
||||||
this.logger.warn(`eBirr webhook: no PaymentIntent for orderNo=${merchantOrderId}`);
|
|
||||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
|
||||||
await this.payments.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
|
||||||
providerTxnId: payload.tradeNo,
|
|
||||||
paidAt: payload.payTime ? new Date(payload.payTime) : undefined,
|
|
||||||
});
|
|
||||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
|
||||||
await this.payments.markPaymentFailed({
|
|
||||||
intentId: intent.id,
|
|
||||||
failureCode: payload.tradeStatus,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this.prisma.paymentIntent.update({
|
|
||||||
where: { id: intent.id },
|
|
||||||
data: {
|
|
||||||
status: mapped as unknown as PaymentIntentStatus,
|
|
||||||
providerTxnId: payload.tradeNo ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await this.markProcessed(eventRow.id);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`eBirr webhook processing failed for ${merchantOrderId}: ${message}`);
|
|
||||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistEvent(input: {
|
|
||||||
externalEventId: string;
|
|
||||||
merchantOrderId: string;
|
|
||||||
providerTxnId?: string;
|
|
||||||
signatureValid: boolean;
|
|
||||||
status: string;
|
|
||||||
payload: EBirrWebhookPayload;
|
|
||||||
}): Promise<{ id: string } | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.paymentWebhookEvent.create({
|
|
||||||
data: {
|
|
||||||
provider: PaymentMethodType.EBIRR,
|
|
||||||
externalEventId: input.externalEventId,
|
|
||||||
merchantOrderId: input.merchantOrderId,
|
|
||||||
providerTxnId: input.providerTxnId,
|
|
||||||
signatureValid: input.signatureValid,
|
|
||||||
status: input.status,
|
|
||||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (
|
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
err.code === 'P2002'
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
|
||||||
await this.prisma.paymentWebhookEvent.update({
|
|
||||||
where: { id: eventId },
|
|
||||||
data: { processedAt: new Date(), processingError },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Prisma, PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import {
|
|
||||||
TelebirrProvider,
|
|
||||||
TelebirrWebhookPayload,
|
|
||||||
ProviderPaymentStatus,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
|
||||||
import { PaymentsService } from '../payments.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TelebirrWebhookService {
|
|
||||||
private readonly logger = new Logger(TelebirrWebhookService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly provider: TelebirrProvider,
|
|
||||||
private readonly payments: PaymentsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handle(payload: TelebirrWebhookPayload): Promise<void> {
|
|
||||||
const merchantOrderId = payload.merch_order_id;
|
|
||||||
const externalEventId = this.buildExternalEventId(payload);
|
|
||||||
// TODO: re-enable Telebirr public-key signature verification — skipped for now
|
|
||||||
// const signatureValid = this.provider.verifyWebhookSignature(
|
|
||||||
// payload as unknown as Record<string, unknown>,
|
|
||||||
// );
|
|
||||||
const signatureValid = true;
|
|
||||||
|
|
||||||
const eventRow = await this.persistEvent({
|
|
||||||
externalEventId,
|
|
||||||
merchantOrderId,
|
|
||||||
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
|
||||||
signatureValid,
|
|
||||||
status: payload.trade_status,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!eventRow) {
|
|
||||||
this.logger.log(
|
|
||||||
`Telebirr webhook duplicate: ${externalEventId} — short-circuit OK`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: re-enable signature gate once verifyWebhookSignature is restored
|
|
||||||
// if (!signatureValid) {
|
|
||||||
// this.logger.warn(
|
|
||||||
// `Telebirr webhook signature invalid for merch_order_id=${merchantOrderId}`,
|
|
||||||
// );
|
|
||||||
// await this.markProcessed(eventRow.id, 'signature-invalid');
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
|
||||||
where: { merchantOrderId },
|
|
||||||
});
|
|
||||||
if (!intent) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Telebirr webhook: no PaymentIntent for merch_order_id=${merchantOrderId}`,
|
|
||||||
);
|
|
||||||
await this.markProcessed(eventRow.id, 'intent-not-found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (mapped === ProviderPaymentStatus.SUCCEEDED) {
|
|
||||||
await this.payments.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
|
||||||
providerTxnId: payload.trans_id ?? payload.payment_order_id,
|
|
||||||
paidAt: this.parseEpochSeconds(payload.trans_end_time),
|
|
||||||
});
|
|
||||||
} else if (mapped === ProviderPaymentStatus.FAILED) {
|
|
||||||
await this.payments.markPaymentFailed({
|
|
||||||
intentId: intent.id,
|
|
||||||
failureCode: payload.trade_status,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this.prisma.paymentIntent.update({
|
|
||||||
where: { id: intent.id },
|
|
||||||
data: {
|
|
||||||
status: mapped as unknown as PaymentIntentStatus,
|
|
||||||
providerTxnId: payload.trans_id ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await this.markProcessed(eventRow.id);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(
|
|
||||||
`Telebirr webhook processing failed for ${merchantOrderId}: ${message}`,
|
|
||||||
);
|
|
||||||
await this.markProcessed(eventRow.id, `processing-error: ${message}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildExternalEventId(payload: TelebirrWebhookPayload): string {
|
|
||||||
return `${payload.payment_order_id}_${payload.trade_status}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistEvent(input: {
|
|
||||||
externalEventId: string;
|
|
||||||
merchantOrderId: string;
|
|
||||||
providerTxnId?: string;
|
|
||||||
signatureValid: boolean;
|
|
||||||
status: string;
|
|
||||||
payload: TelebirrWebhookPayload;
|
|
||||||
}): Promise<{ id: string } | null> {
|
|
||||||
try {
|
|
||||||
return await this.prisma.paymentWebhookEvent.create({
|
|
||||||
data: {
|
|
||||||
provider: PaymentMethodType.TELEBIRR,
|
|
||||||
externalEventId: input.externalEventId,
|
|
||||||
merchantOrderId: input.merchantOrderId,
|
|
||||||
providerTxnId: input.providerTxnId,
|
|
||||||
signatureValid: input.signatureValid,
|
|
||||||
status: input.status,
|
|
||||||
payload: input.payload as unknown as Prisma.InputJsonValue,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (
|
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
err.code === 'P2002'
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async markProcessed(eventId: string, processingError?: string): Promise<void> {
|
|
||||||
await this.prisma.paymentWebhookEvent.update({
|
|
||||||
where: { id: eventId },
|
|
||||||
data: { processedAt: new Date(), processingError },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseEpochSeconds(raw: string | undefined): Date | undefined {
|
|
||||||
if (!raw) return undefined;
|
|
||||||
const n = parseInt(raw, 10);
|
|
||||||
if (Number.isNaN(n)) return undefined;
|
|
||||||
return new Date(n * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
WaafiProvider,
|
|
||||||
WaafiWebhookPayload,
|
|
||||||
ProviderPaymentStatus,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
|
|
||||||
import { PrismaService } from '../../../common/prisma.service';
|
|
||||||
import { PaymentsService } from '../payments.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class WaafiWebhookService {
|
|
||||||
private readonly logger = new Logger(WaafiWebhookService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private prisma: PrismaService,
|
|
||||||
private paymentsService: PaymentsService,
|
|
||||||
private waafiProvider: WaafiProvider,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handleWebhook(payload: WaafiWebhookPayload): Promise<{ received: boolean }> {
|
|
||||||
this.logger.log(
|
|
||||||
`Waafi webhook received: event=${payload.eventType} ref=${payload.params?.referenceId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const signatureValid = this.waafiProvider.verifyWebhookSignature(
|
|
||||||
payload as unknown as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const merchantOrderId = payload.params?.referenceId;
|
|
||||||
const transactionId = payload.params?.transactionId;
|
|
||||||
const state = payload.params?.state;
|
|
||||||
|
|
||||||
await this.prisma.paymentWebhookEvent.create({
|
|
||||||
data: {
|
|
||||||
provider: PaymentMethodType.WAAFI,
|
|
||||||
externalEventId: payload.requestId,
|
|
||||||
merchantOrderId,
|
|
||||||
providerTxnId: transactionId,
|
|
||||||
signatureValid,
|
|
||||||
status: state || 'UNKNOWN',
|
|
||||||
payload: payload as any,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!signatureValid) {
|
|
||||||
this.logger.warn(`Waafi webhook signature invalid for ref=${merchantOrderId}`);
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!merchantOrderId) {
|
|
||||||
this.logger.error('Waafi webhook missing referenceId');
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const intent = await this.prisma.paymentIntent.findFirst({
|
|
||||||
where: { merchantOrderId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!intent) {
|
|
||||||
this.logger.warn(`No PaymentIntent found for merchantOrderId=${merchantOrderId}`);
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const mappedStatus = this.waafiProvider.mapState(state);
|
|
||||||
|
|
||||||
if (mappedStatus === ProviderPaymentStatus.SUCCEEDED) {
|
|
||||||
await this.paymentsService.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
|
||||||
providerTxnId: transactionId,
|
|
||||||
});
|
|
||||||
this.logger.log(`Waafi payment succeeded: intent=${intent.id} txn=${transactionId}`);
|
|
||||||
} else if (mappedStatus === ProviderPaymentStatus.FAILED) {
|
|
||||||
await this.paymentsService.markPaymentFailed({
|
|
||||||
intentId: intent.id,
|
|
||||||
failureCode: state,
|
|
||||||
failureMessage: payload.params?.description,
|
|
||||||
});
|
|
||||||
this.logger.log(`Waafi payment failed: intent=${intent.id} state=${state}`);
|
|
||||||
} else {
|
|
||||||
await this.prisma.paymentIntent.update({
|
|
||||||
where: { id: intent.id },
|
|
||||||
data: {
|
|
||||||
status: mappedStatus as unknown as PaymentIntentStatus,
|
|
||||||
providerTxnId: transactionId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.log(`Waafi payment status updated: intent=${intent.id} status=${mappedStatus}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import {All, Body, Controller, Headers, HttpCode, HttpStatus, Logger, Post} from '@nestjs/common';
|
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import {
|
|
||||||
TelebirrWebhookPayload,
|
|
||||||
CbeBirrWebhookPayload,
|
|
||||||
EBirrWebhookPayload,
|
|
||||||
CardWebhookPayload,
|
|
||||||
} from '@edr/payment-providers';
|
|
||||||
import { TelebirrWebhookService } from './telebirr-webhook.service';
|
|
||||||
import { CbeBirrWebhookService } from './cbe-birr-webhook.service';
|
|
||||||
import { EBirrWebhookService } from './ebirr-webhook.service';
|
|
||||||
import { CardWebhookService } from './card-webhook.service';
|
|
||||||
import { WaafiWebhookService } from './waafi-webhook.service';
|
|
||||||
|
|
||||||
@ApiTags('Payment Webhooks')
|
|
||||||
@Controller('payments/webhooks')
|
|
||||||
export class WebhooksController {
|
|
||||||
private readonly logger = new Logger(WebhooksController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly telebirr: TelebirrWebhookService,
|
|
||||||
private readonly cbeBirr: CbeBirrWebhookService,
|
|
||||||
private readonly eBirr: EBirrWebhookService,
|
|
||||||
private readonly card: CardWebhookService,
|
|
||||||
private readonly waafi: WaafiWebhookService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@All('telebirr')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Telebirr payment notification callback (Ethiopia)',
|
|
||||||
description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.'
|
|
||||||
})
|
|
||||||
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Telebirr webhook Called`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.telebirr.handle(payload);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { code: '0', message: 'OK' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('cbe-birr')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'CBE Birr payment notification callback (Ethiopia)',
|
|
||||||
description: 'Webhook endpoint for Commercial Bank of Ethiopia payment status updates.'
|
|
||||||
})
|
|
||||||
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
|
|
||||||
try {
|
|
||||||
await this.cbeBirr.handle(payload);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`CBE Birr webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('ebirr')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'eBirr payment notification callback (Ethiopia)',
|
|
||||||
description: 'Webhook endpoint for eBirr electronic payment gateway status updates.'
|
|
||||||
})
|
|
||||||
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
|
|
||||||
try {
|
|
||||||
await this.eBirr.handle(payload);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`eBirr webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { code: '0000', message: 'success' };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('card')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Card payment notification callback (International)',
|
|
||||||
description: 'Webhook endpoint for international card payments (Visa, Mastercard) via Stripe.'
|
|
||||||
})
|
|
||||||
async receiveCard(
|
|
||||||
@Body() payload: CardWebhookPayload,
|
|
||||||
@Headers('stripe-signature') signature: string,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await this.card.handle(payload, signature);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`Card webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('waafi')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Waafi payment notification callback (Djibouti)',
|
|
||||||
description: 'Webhook endpoint for Waafi mobile money payment status updates. Used by Djiboutian passengers.'
|
|
||||||
})
|
|
||||||
async receiveWaafi(@Body() payload: any) {
|
|
||||||
try {
|
|
||||||
await this.waafi.handleWebhook(payload);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(`Waafi webhook handler threw: ${message}`);
|
|
||||||
}
|
|
||||||
return { responseCode: '2001', responseMsg: 'Success' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -28,9 +28,7 @@ export default function BookingLayout({
|
|||||||
<div>
|
<div>
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
<div className="bg-white dark:bg-gray-800 border-b dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-800 border-b dark:border-gray-700">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<ProgressIndicator currentStep={currentStep} />
|
||||||
<ProgressIndicator currentStep={currentStep} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -7,16 +7,338 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { CheckCircle, ExternalLink, Loader2 } from 'lucide-react';
|
import { CheckCircle, ExternalLink, Loader2, CalendarDays, X, Globe } from 'lucide-react';
|
||||||
|
import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysInEthiopianMonth } from '@/lib/ethiopian-calendar';
|
||||||
|
|
||||||
|
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
|
function daysInGCMonth(y: number, m: number) {
|
||||||
|
return new Date(y, m, 0).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function DobPickerModal({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (iso: string) => void;
|
||||||
|
error?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [manualMode, setManualMode] = useState(false);
|
||||||
|
const [calType, setCalType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const currentEthYear = gregorianToEthiopian(new Date()).year;
|
||||||
|
|
||||||
|
// Parse stored ISO value (always Gregorian)
|
||||||
|
const parsed = value ? value.split('-') : [];
|
||||||
|
const initGCYear = parsed[0] ? parseInt(parsed[0]) : currentYear - 25;
|
||||||
|
const initGCMonth = parsed[1] ? parseInt(parsed[1]) : 1;
|
||||||
|
const initGCDay = parsed[2] ? parseInt(parsed[2]) : 1;
|
||||||
|
|
||||||
|
// Gregorian drum state
|
||||||
|
const [selGCYear, setSelGCYear] = useState(initGCYear);
|
||||||
|
const [selGCMonth, setSelGCMonth] = useState(initGCMonth);
|
||||||
|
const [selGCDay, setSelGCDay] = useState(initGCDay);
|
||||||
|
|
||||||
|
// Ethiopian drum state — initialise from parsed value if present
|
||||||
|
const initEth = value ? gregorianToEthiopian(new Date(initGCYear, initGCMonth - 1, initGCDay)) : { year: currentEthYear - 25, month: 1, day: 1 };
|
||||||
|
const [selEthYear, setSelEthYear] = useState(initEth.year);
|
||||||
|
const [selEthMonth, setSelEthMonth] = useState(initEth.month);
|
||||||
|
const [selEthDay, setSelEthDay] = useState(initEth.day);
|
||||||
|
|
||||||
|
// Manual inputs
|
||||||
|
const [manDay, setManDay] = useState(parsed[2] ? String(parseInt(parsed[2])) : '');
|
||||||
|
const [manMonth, setManMonth] = useState(parsed[1] ? String(parseInt(parsed[1])) : '');
|
||||||
|
const [manYear, setManYear] = useState(parsed[0] || '');
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const gcMaxDay = daysInGCMonth(selGCYear, selGCMonth);
|
||||||
|
const ethMaxDay = getDaysInEthiopianMonth(selEthYear, selEthMonth);
|
||||||
|
const gcSafeDay = Math.min(selGCDay, gcMaxDay);
|
||||||
|
const ethSafeDay = Math.min(selEthDay, ethMaxDay);
|
||||||
|
|
||||||
|
const gcYears = Array.from({ length: 100 }, (_, i) => currentYear - i);
|
||||||
|
const ethYears = Array.from({ length: 100 }, (_, i) => currentEthYear - i);
|
||||||
|
const gcMonths = GC_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
|
||||||
|
const ethMonths = ETHIOPIAN_MONTHS.map((m, i) => ({ label: m, value: i + 1 }));
|
||||||
|
const gcDays = Array.from({ length: gcMaxDay }, (_, i) => i + 1);
|
||||||
|
const ethDays = Array.from({ length: ethMaxDay }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
const dayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const monthRef = useRef<HTMLDivElement>(null);
|
||||||
|
const yearRef = useRef<HTMLDivElement>(null);
|
||||||
|
const ITEM_H = 48;
|
||||||
|
|
||||||
|
const scrollTo = (ref: React.RefObject<HTMLDivElement>, idx: number) => {
|
||||||
|
ref.current?.scrollTo({ top: Math.max(0, idx) * ITEM_H, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll drums to current selection when opened or calType changed
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || manualMode) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (calType === 'gregorian') {
|
||||||
|
scrollTo(dayRef, gcSafeDay - 1);
|
||||||
|
scrollTo(monthRef, selGCMonth - 1);
|
||||||
|
scrollTo(yearRef, gcYears.indexOf(selGCYear));
|
||||||
|
} else {
|
||||||
|
scrollTo(dayRef, ethSafeDay - 1);
|
||||||
|
scrollTo(monthRef, selEthMonth - 1);
|
||||||
|
scrollTo(yearRef, ethYears.indexOf(selEthYear));
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, manualMode, calType]);
|
||||||
|
|
||||||
|
const makeScrollHandler = (
|
||||||
|
ref: React.RefObject<HTMLDivElement>,
|
||||||
|
setter: (v: number) => void,
|
||||||
|
getList: () => number[],
|
||||||
|
) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const list = getList();
|
||||||
|
const idx = Math.round(el.scrollTop / ITEM_H);
|
||||||
|
const clamped = Math.max(0, Math.min(idx, list.length - 1));
|
||||||
|
setter(list[clamped]);
|
||||||
|
el.scrollTo({ top: clamped * ITEM_H, behavior: 'smooth' });
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const gcDayHandler = useRef(makeScrollHandler(dayRef, setSelGCDay, () => Array.from({ length: daysInGCMonth(selGCYear, selGCMonth) }, (_, i) => i + 1))).current;
|
||||||
|
const gcMonthHandler = useRef(makeScrollHandler(monthRef, setSelGCMonth, () => GC_MONTHS.map((_, i) => i + 1))).current;
|
||||||
|
const gcYearHandler = useRef(makeScrollHandler(yearRef, setSelGCYear, () => Array.from({ length: 100 }, (_, i) => new Date().getFullYear() - i))).current;
|
||||||
|
|
||||||
|
const ethDayHandler = useRef(makeScrollHandler(dayRef, setSelEthDay, () => Array.from({ length: getDaysInEthiopianMonth(selEthYear, selEthMonth) }, (_, i) => i + 1))).current;
|
||||||
|
const ethMonthHandler = useRef(makeScrollHandler(monthRef, setSelEthMonth, () => ETHIOPIAN_MONTHS.map((_, i) => i + 1))).current;
|
||||||
|
const ethYearHandler = useRef(makeScrollHandler(yearRef, setSelEthYear, () => Array.from({ length: 100 }, (_, i) => gregorianToEthiopian(new Date()).year - i))).current;
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
let gregDate: Date;
|
||||||
|
if (calType === 'gregorian') {
|
||||||
|
const d = Math.min(selGCDay, daysInGCMonth(selGCYear, selGCMonth));
|
||||||
|
gregDate = new Date(selGCYear, selGCMonth - 1, d);
|
||||||
|
} else {
|
||||||
|
const d = Math.min(selEthDay, getDaysInEthiopianMonth(selEthYear, selEthMonth));
|
||||||
|
gregDate = ethiopianToGregorian({ year: selEthYear, month: selEthMonth, day: d });
|
||||||
|
}
|
||||||
|
const iso = `${gregDate.getFullYear()}-${String(gregDate.getMonth() + 1).padStart(2,'0')}-${String(gregDate.getDate()).padStart(2,'0')}`;
|
||||||
|
onChange(iso);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmManual = () => {
|
||||||
|
const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear);
|
||||||
|
if (!d || !m || !y || m < 1 || m > 12 || d < 1 || d > daysInGCMonth(y, m) || y < currentYear - 110 || y > currentYear) return;
|
||||||
|
onChange(`${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const manualValid = (() => {
|
||||||
|
const d = parseInt(manDay), m = parseInt(manMonth), y = parseInt(manYear);
|
||||||
|
return d >= 1 && m >= 1 && m <= 12 && y >= currentYear - 110 && y <= currentYear && d <= daysInGCMonth(y, m);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Display: always show Gregorian ISO as human-readable, with calendar label
|
||||||
|
const displayValue = (() => {
|
||||||
|
if (!value) return '';
|
||||||
|
const [y, mo, d] = value.split('-').map(Number);
|
||||||
|
const gcStr = `${GC_MONTHS[mo - 1]} ${d}, ${y}`;
|
||||||
|
if (calType === 'ethiopian') {
|
||||||
|
const eth = gregorianToEthiopian(new Date(y, mo - 1, d));
|
||||||
|
return `${ETHIOPIAN_MONTHS[eth.month - 1]} ${eth.day}, ${eth.year} (${gcStr})`;
|
||||||
|
}
|
||||||
|
return gcStr;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Confirm button label
|
||||||
|
const confirmLabel = (() => {
|
||||||
|
if (manualMode) return manualValid ? `Confirm — ${manDay}/${manMonth}/${manYear}` : 'Confirm';
|
||||||
|
if (calType === 'gregorian') {
|
||||||
|
const d = Math.min(selGCDay, gcMaxDay);
|
||||||
|
return `Confirm — ${GC_MONTHS[selGCMonth - 1]} ${d}, ${selGCYear}`;
|
||||||
|
} else {
|
||||||
|
const d = Math.min(selEthDay, ethMaxDay);
|
||||||
|
const gcDate = ethiopianToGregorian({ year: selEthYear, month: selEthMonth, day: d });
|
||||||
|
return `Confirm — ${ETHIOPIAN_MONTHS[selEthMonth - 1]} ${d}, ${selEthYear} (GC: ${GC_MONTHS[gcDate.getMonth()]} ${gcDate.getDate()}, ${gcDate.getFullYear()})`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const col = (
|
||||||
|
ref: React.RefObject<HTMLDivElement>,
|
||||||
|
list: Array<{ label: string; value: number }>,
|
||||||
|
selected: number,
|
||||||
|
setter: (v: number) => void,
|
||||||
|
scrollHandler: () => void,
|
||||||
|
) => (
|
||||||
|
<div className="flex-1 flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onScroll={scrollHandler}
|
||||||
|
className="h-full overflow-y-auto scrollbar-hide"
|
||||||
|
style={{ scrollSnapType: 'y mandatory' }}
|
||||||
|
>
|
||||||
|
<div style={{ height: ITEM_H * 2 }} />
|
||||||
|
{list.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
style={{ height: ITEM_H, scrollSnapAlign: 'center', cursor: 'pointer' }}
|
||||||
|
onClick={() => { setter(item.value); ref.current?.scrollTo({ top: idx * ITEM_H, behavior: 'smooth' }); }}
|
||||||
|
className={`flex items-center justify-center text-sm font-medium transition-all select-none ${
|
||||||
|
item.value === selected ? 'text-primary font-bold text-base' : 'text-gray-400 dark:text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ height: ITEM_H * 2 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className={`input-field w-full text-left flex items-center justify-between ${
|
||||||
|
error ? 'border-red-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={displayValue ? 'text-gray-900 dark:text-white text-sm' : 'text-gray-400 text-sm'}>
|
||||||
|
{displayValue || 'Select date of birth'}
|
||||||
|
</span>
|
||||||
|
<CalendarDays className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[99] bg-black/50" onClick={() => setOpen(false)} />
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-0 bottom-0 z-[100] bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl"
|
||||||
|
style={{ animation: 'dob-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-base font-bold text-gray-900 dark:text-white">Date of Birth</h2>
|
||||||
|
{!manualMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCalType(c => c === 'gregorian' ? 'ethiopian' : 'gregorian')}
|
||||||
|
className="flex items-center gap-1 text-xs font-semibold px-2.5 py-1 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
{calType === 'gregorian' ? 'ET' : 'GC'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setManualMode(!manualMode)}
|
||||||
|
className="text-xs text-gray-500 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{manualMode ? 'Use scroll' : 'Enter manually'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar type label */}
|
||||||
|
{!manualMode && (
|
||||||
|
<div className="px-5 pt-2 pb-0">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{calType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar (ኢትዮጵያ)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{manualMode ? (
|
||||||
|
<div className="px-5 py-5 space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Day</label>
|
||||||
|
<input type="number" min={1} max={31} value={manDay} onChange={(e) => setManDay(e.target.value)} placeholder="DD" className="input-field text-center text-lg font-semibold" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Month</label>
|
||||||
|
<input type="number" min={1} max={12} value={manMonth} onChange={(e) => setManMonth(e.target.value)} placeholder="MM" className="input-field text-center text-lg font-semibold" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Year</label>
|
||||||
|
<input type="number" min={currentYear - 110} max={currentYear} value={manYear} onChange={(e) => setManYear(e.target.value)} placeholder="YYYY" className="input-field text-center text-lg font-semibold" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{manDay && manMonth && manYear && !manualValid && (
|
||||||
|
<p className="text-red-500 text-xs">Please enter a valid Gregorian date</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex px-5 pt-2 pb-1">
|
||||||
|
{['Day', 'Month', 'Year'].map(l => (
|
||||||
|
<div key={l} className="flex-1 text-center text-xs font-semibold text-gray-400 uppercase tracking-wide">{l}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative px-5 pb-2" style={{ height: ITEM_H * 5 }}>
|
||||||
|
<div
|
||||||
|
className="absolute left-5 right-5 pointer-events-none rounded-xl border border-primary/30 bg-primary/5 dark:bg-primary/10"
|
||||||
|
style={{ top: ITEM_H * 2, height: ITEM_H }}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 h-full">
|
||||||
|
{calType === 'gregorian' ? (
|
||||||
|
<>
|
||||||
|
{col(dayRef, gcDays.map(d => ({ label: String(d), value: d })), gcSafeDay, setSelGCDay, gcDayHandler)}
|
||||||
|
{col(monthRef, gcMonths, selGCMonth, setSelGCMonth, gcMonthHandler)}
|
||||||
|
{col(yearRef, gcYears.map(y => ({ label: String(y), value: y })), selGCYear, setSelGCYear, gcYearHandler)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{col(dayRef, ethDays.map(d => ({ label: String(d), value: d })), ethSafeDay, setSelEthDay, ethDayHandler)}
|
||||||
|
{col(monthRef, ethMonths, selEthMonth, setSelEthMonth, ethMonthHandler)}
|
||||||
|
{col(yearRef, ethYears.map(y => ({ label: String(y), value: y })), selEthYear, setSelEthYear, ethYearHandler)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-5 pb-8 pt-3 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={manualMode ? confirmManual : confirm}
|
||||||
|
disabled={manualMode && !manualValid}
|
||||||
|
className="px-6 py-2.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>{`@keyframes dob-slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}`}</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const passengerSchema = z.object({
|
const passengerSchema = z.object({
|
||||||
name: z.string().min(2, 'Name is required'),
|
name: z.string().min(2, 'Full name is required (min 2 characters)'),
|
||||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||||
gender: z.enum(['Male', 'Female']).optional(),
|
gender: z.string().min(1, 'Gender is required'),
|
||||||
nationality: z.string().min(1, 'Nationality is required'),
|
nationality: z.string().min(1, 'Nationality is required'),
|
||||||
phone: z.string().optional(),
|
phone: z.string().min(1, 'Phone number is required'),
|
||||||
email: z.string().email('Invalid email').optional().or(z.literal('')),
|
email: z.string().optional(),
|
||||||
nationalId: z.string().optional(),
|
nationalId: z.string().optional(),
|
||||||
passportNumber: z.string().optional(),
|
passportNumber: z.string().optional(),
|
||||||
passportCountry: z.string().optional(),
|
passportCountry: z.string().optional(),
|
||||||
@@ -26,15 +348,25 @@ const passengerSchema = z.object({
|
|||||||
faydaVerified: z.boolean().optional(),
|
faydaVerified: z.boolean().optional(),
|
||||||
faydaSub: z.string().optional(),
|
faydaSub: z.string().optional(),
|
||||||
formExpanded: z.boolean().optional(),
|
formExpanded: z.boolean().optional(),
|
||||||
}).refine((data) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian') {
|
if (data.gender !== 'Male' && data.gender !== 'Female') {
|
||||||
return data.passportNumber && data.passportNumber.length > 0 &&
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] });
|
||||||
data.passportCountry && data.passportCountry.length > 0;
|
}
|
||||||
|
if (data.email && data.email.trim().length > 0) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(data.email)) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['email'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
|
||||||
|
if (isNonEthiopian) {
|
||||||
|
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] });
|
||||||
|
}
|
||||||
|
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}, {
|
|
||||||
message: 'Passport number and country are required for non-Ethiopian passengers',
|
|
||||||
path: ['passportNumber'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
@@ -57,7 +389,8 @@ export default function PassengersPage() {
|
|||||||
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
||||||
|
|
||||||
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
|
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema as any),
|
||||||
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
passengers: Array.from({ length: totalPassengers }, () => ({
|
passengers: Array.from({ length: totalPassengers }, () => ({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -418,82 +751,80 @@ export default function PassengersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* Full Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.name`)}
|
{...register(`passengers.${index}.name`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||||
placeholder="Full name as per ID"
|
placeholder="Full name as per ID"
|
||||||
value={passengers[index]?.name || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.name && (
|
{errors.passengers?.[index]?.name && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Date of Birth */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||||
<input
|
<DobPickerModal
|
||||||
type="date"
|
|
||||||
{...register(`passengers.${index}.dateOfBirth`)}
|
|
||||||
className="input-field"
|
|
||||||
value={passengers[index]?.dateOfBirth || ''}
|
value={passengers[index]?.dateOfBirth || ''}
|
||||||
onChange={(e) => setValue(`passengers.${index}.dateOfBirth`, e.target.value)}
|
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||||
|
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.dateOfBirth && (
|
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gender */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||||
<select
|
<select
|
||||||
{...register(`passengers.${index}.gender`)}
|
{...register(`passengers.${index}.gender`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||||
value={passengers[index]?.gender || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
|
|
||||||
>
|
>
|
||||||
<option value="">Select gender</option>
|
<option value="">Select gender</option>
|
||||||
<option value="Male">Male</option>
|
<option value="Male">Male</option>
|
||||||
<option value="Female">Female</option>
|
<option value="Female">Female</option>
|
||||||
</select>
|
</select>
|
||||||
|
{errors.passengers?.[index]?.gender && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Nationality (read-only) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.nationality`)}
|
{...register(`passengers.${index}.nationality`)}
|
||||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||||
readOnly
|
readOnly
|
||||||
disabled
|
disabled
|
||||||
value={passengers[index]?.nationality || ''}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.phone`)}
|
{...register(`passengers.${index}.phone`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||||
placeholder="+251911234567"
|
placeholder="+251911234567"
|
||||||
value={passengers[index]?.phone || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
|
{errors.passengers?.[index]?.phone && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
{...register(`passengers.${index}.email`)}
|
{...register(`passengers.${index}.email`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
value={passengers[index]?.email || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.email && (
|
{errors.passengers?.[index]?.email && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -501,113 +832,109 @@ export default function PassengersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* Full Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Full Name *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.name`)}
|
{...register(`passengers.${index}.name`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.name ? 'border-red-500' : ''}`}
|
||||||
placeholder="Full name as per passport"
|
placeholder="Full name as per passport"
|
||||||
value={passengers[index]?.name || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.name`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.name && (
|
{errors.passengers?.[index]?.name && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.name?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.name?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Date of Birth */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Date of Birth *</label>
|
||||||
<input
|
<DobPickerModal
|
||||||
type="date"
|
|
||||||
{...register(`passengers.${index}.dateOfBirth`)}
|
|
||||||
className="input-field"
|
|
||||||
value={passengers[index]?.dateOfBirth || ''}
|
value={passengers[index]?.dateOfBirth || ''}
|
||||||
onChange={(e) => setValue(`passengers.${index}.dateOfBirth`, e.target.value)}
|
onChange={(iso) => setValue(`passengers.${index}.dateOfBirth`, iso, { shouldValidate: true })}
|
||||||
|
error={errors.passengers?.[index]?.dateOfBirth?.message}
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.dateOfBirth && (
|
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.dateOfBirth?.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gender */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Gender *</label>
|
||||||
<select
|
<select
|
||||||
{...register(`passengers.${index}.gender`)}
|
{...register(`passengers.${index}.gender`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.gender ? 'border-red-500' : ''}`}
|
||||||
value={passengers[index]?.gender || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.gender`, e.target.value as any)}
|
|
||||||
>
|
>
|
||||||
<option value="">Select gender</option>
|
<option value="">Select gender</option>
|
||||||
<option value="Male">Male</option>
|
<option value="Male">Male</option>
|
||||||
<option value="Female">Female</option>
|
<option value="Female">Female</option>
|
||||||
</select>
|
</select>
|
||||||
|
{errors.passengers?.[index]?.gender && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.gender?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Nationality (read-only) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Nationality</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.nationality`)}
|
{...register(`passengers.${index}.nationality`)}
|
||||||
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
className="input-field bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
|
||||||
readOnly
|
readOnly
|
||||||
disabled
|
disabled
|
||||||
value={passengers[index]?.nationality || ''}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone Number *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.phone`)}
|
{...register(`passengers.${index}.phone`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.phone ? 'border-red-500' : ''}`}
|
||||||
placeholder="+254712345678"
|
placeholder="+254712345678"
|
||||||
value={passengers[index]?.phone || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.phone`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
|
{errors.passengers?.[index]?.phone && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.phone?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
{...register(`passengers.${index}.email`)}
|
{...register(`passengers.${index}.email`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.email ? 'border-red-500' : ''}`}
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
value={passengers[index]?.email || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.email`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.email && (
|
{errors.passengers?.[index]?.email && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.email?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.email?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Passport fields */}
|
||||||
<div className="border-t dark:border-gray-700 pt-4 mt-4">
|
<div className="border-t dark:border-gray-700 pt-4 mt-4">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Passport Details</h4>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Passport Number *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.passportNumber`)}
|
{...register(`passengers.${index}.passportNumber`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.passportNumber ? 'border-red-500' : ''}`}
|
||||||
placeholder="P1234567"
|
placeholder="P1234567"
|
||||||
value={passengers[index]?.passportNumber || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.passportNumber`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.passportNumber && (
|
{errors.passengers?.[index]?.passportNumber && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportNumber?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country / Authority *</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country *</label>
|
||||||
<input
|
<input
|
||||||
{...register(`passengers.${index}.passportCountry`)}
|
{...register(`passengers.${index}.passportCountry`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`}
|
||||||
placeholder="e.g., Djibouti / Government of Djibouti"
|
placeholder="e.g., Djibouti"
|
||||||
value={passengers[index]?.passportCountry || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.passportCountry`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{errors.passengers?.[index]?.passportCountry && (
|
{errors.passengers?.[index]?.passportCountry && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -617,8 +944,6 @@ export default function PassengersPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
{...register(`passengers.${index}.passportIssueDate`)}
|
{...register(`passengers.${index}.passportIssueDate`)}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
value={passengers[index]?.passportIssueDate || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.passportIssueDate`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -628,8 +953,6 @@ export default function PassengersPage() {
|
|||||||
type="date"
|
type="date"
|
||||||
{...register(`passengers.${index}.passportExpiryDate`)}
|
{...register(`passengers.${index}.passportExpiryDate`)}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
value={passengers[index]?.passportExpiryDate || ''}
|
|
||||||
onChange={(e) => setValue(`passengers.${index}.passportExpiryDate`, e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -652,7 +975,18 @@ export default function PassengersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button type="button" onClick={() => router.back()} className="btn-secondary flex-1" disabled={saving}>
|
<button type="button" onClick={() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
origin: searchCriteria.originStationId,
|
||||||
|
destination: searchCriteria.destinationStationId,
|
||||||
|
date: searchCriteria.departureDate,
|
||||||
|
adults: String(searchCriteria.adultCount),
|
||||||
|
children: String(searchCriteria.childCount),
|
||||||
|
nationality: searchCriteria.nationality,
|
||||||
|
...(searchCriteria.promoCode && { promoCode: searchCriteria.promoCode }),
|
||||||
|
});
|
||||||
|
router.push(`/booking/results?${params}`);
|
||||||
|
}} className="btn-secondary flex-1" disabled={saving}>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
<button type="submit" className="btn-primary flex-1" disabled={saving}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { Schedule } from '@/types';
|
import { Schedule } from '@/types';
|
||||||
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, ChevronDown, ChevronUp, MapPin, Gift } from 'lucide-react';
|
import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Loader2, Check, X, MapPin, Gift, Train } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
@@ -14,19 +14,39 @@ export default function ResultsPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule);
|
||||||
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
const [selectedClasses, setSelectedClasses] = useState<Record<string, string>>({});
|
||||||
const [expandedSchedules, setExpandedSchedules] = useState<Record<string, boolean>>({});
|
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||||
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null);
|
||||||
|
|
||||||
|
const searchCriteria = useBookingStore((s) => s.searchCriteria);
|
||||||
|
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||||
|
|
||||||
|
// Prefer URL params; fall back to persisted store values
|
||||||
const searchData = {
|
const searchData = {
|
||||||
originStationId: searchParams.get('origin') || '',
|
originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '',
|
||||||
destinationStationId: searchParams.get('destination') || '',
|
destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '',
|
||||||
date: searchParams.get('date') || '',
|
date: searchParams.get('date') || searchCriteria?.departureDate || '',
|
||||||
adultCount: parseInt(searchParams.get('adults') || '1'),
|
adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1,
|
||||||
childCount: parseInt(searchParams.get('children') || '0'),
|
childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0,
|
||||||
nationality: searchParams.get('nationality') || 'ETHIOPIAN',
|
nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN',
|
||||||
promoCode: searchParams.get('promoCode') || '',
|
promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sync URL params back into store whenever they are present in the URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('origin')) {
|
||||||
|
setSearchCriteria({
|
||||||
|
originStationId: searchParams.get('origin')!,
|
||||||
|
destinationStationId: searchParams.get('destination')!,
|
||||||
|
departureDate: searchParams.get('date')!,
|
||||||
|
adultCount: parseInt(searchParams.get('adults') || '1'),
|
||||||
|
childCount: parseInt(searchParams.get('children') || '0'),
|
||||||
|
nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER',
|
||||||
|
promoCode: searchParams.get('promoCode') || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchData.promoCode) {
|
if (searchData.promoCode) {
|
||||||
apiClient
|
apiClient
|
||||||
@@ -54,6 +74,7 @@ export default function ResultsPage() {
|
|||||||
adults: searchData.adultCount.toString(),
|
adults: searchData.adultCount.toString(),
|
||||||
children: searchData.childCount.toString(),
|
children: searchData.childCount.toString(),
|
||||||
nationality: searchData.nationality,
|
nationality: searchData.nationality,
|
||||||
|
...(searchData.promoCode && { promoCode: searchData.promoCode }),
|
||||||
});
|
});
|
||||||
return `/booking/search?${params}`;
|
return `/booking/search?${params}`;
|
||||||
};
|
};
|
||||||
@@ -73,18 +94,8 @@ export default function ResultsPage() {
|
|||||||
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
enabled: !!searchData.originStationId && !!searchData.destinationStationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleExpanded = (scheduleId: string) => {
|
|
||||||
setExpandedSchedules(prev => ({
|
|
||||||
...prev,
|
|
||||||
[scheduleId]: !prev[scheduleId]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectClass = (scheduleId: string, seatClass: string) => {
|
const handleSelectClass = (scheduleId: string, seatClass: string) => {
|
||||||
setSelectedClasses(prev => ({
|
setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass }));
|
||||||
...prev,
|
|
||||||
[scheduleId]: seatClass
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (schedule: Schedule) => {
|
const handleSelect = (schedule: Schedule) => {
|
||||||
@@ -180,31 +191,127 @@ export default function ResultsPage() {
|
|||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
|
||||||
|
{/* Class selection modal */}
|
||||||
|
{classModal && (() => {
|
||||||
|
const scheduleId = classModal.scheduleId || classModal.id || '';
|
||||||
|
const selectedClass = selectedClasses[scheduleId];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="fixed inset-0 z-[99] bg-black/50 backdrop-blur-sm" onClick={() => setClassModal(null)} />
|
||||||
|
{/* Drawer */}
|
||||||
|
<div className="fixed inset-y-0 right-0 z-[100] w-full sm:w-[640px] bg-white dark:bg-gray-900 shadow-2xl flex flex-col"
|
||||||
|
style={{ animation: 'drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Class</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-1">
|
||||||
|
<Train className="w-3 h-3" />
|
||||||
|
{classModal.trainNumber} · {classModal.origin?.name} → {classModal.destination?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setClassModal(null)}
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class grid */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{classModal.faresByClass.map((fareClass: any) => {
|
||||||
|
const isSelected = selectedClass === fareClass.seatClassName;
|
||||||
|
const availableSeats = classModal.availabilityByClass?.[fareClass.seatClassName] || 0;
|
||||||
|
const isAvailable = availableSeats > 0;
|
||||||
|
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={fareClass.seatClassName}
|
||||||
|
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
|
||||||
|
disabled={!isAvailable}
|
||||||
|
className={`relative w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/5 dark:bg-primary/10 shadow-sm'
|
||||||
|
: isAvailable
|
||||||
|
? 'border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm'
|
||||||
|
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-3 right-3 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
||||||
|
<Check className="w-3.5 h-3.5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white pr-8">
|
||||||
|
{fareClass.seatClassName.replace(/_/g, ' ')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-primary dark:text-white mt-2">
|
||||||
|
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">per adult</p>
|
||||||
|
<p className={`text-xs mt-2 font-medium ${
|
||||||
|
isAvailable ? 'text-green-600 dark:text-green-400' : 'text-red-500'
|
||||||
|
}`}>
|
||||||
|
{isAvailable
|
||||||
|
? `${availableSeats} ${isBedClass ? 'bed' : 'seat'}${availableSeats !== 1 ? 's' : ''} available`
|
||||||
|
: 'Sold out'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center py-8 text-gray-400 text-sm">No seat classes available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => { if (selectedClass) { handleSelect(classModal); setClassModal(null); } }}
|
||||||
|
disabled={!selectedClass}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-40 disabled:cursor-not-allowed shadow-lg"
|
||||||
|
>
|
||||||
|
<span>Continue</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{!selectedClass && (
|
||||||
|
<p className="text-center text-xs text-gray-400 mt-2">Please select a class to continue</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>{`@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}`}</style>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Promo Notification */}
|
{/* Promo Notification */}
|
||||||
{promoData && (
|
{promoData && (
|
||||||
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
|
<div className="mb-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-start gap-3">
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
<Check className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3>
|
<h3 className="font-semibold text-green-900 dark:text-green-200">Promo code applied!</h3>
|
||||||
<p className="text-sm text-green-800 dark:text-green-300 mt-1">
|
<p className="text-sm text-green-800 dark:text-green-300 mt-1">
|
||||||
<span className="font-mono font-bold">{promoData.code}</span> - {promoData.message}
|
<span className="font-mono font-bold">{promoData.code}</span> — {promoData.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<button
|
<button onClick={() => router.push(buildSearchUrl())} className="btn-ghost px-0 py-4 flex items-center gap-2">
|
||||||
onClick={() => router.push(buildSearchUrl())}
|
|
||||||
className="btn-ghost px-0 py-4 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Modify search
|
Modify search
|
||||||
</button>
|
</button>
|
||||||
<h1 className="section-title">Available trains</h1>
|
<h1 className="section-title">Available schedules</h1>
|
||||||
<div className="flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
|
<div className="hidden sm:flex flex-wrap items-center gap-4 text-gray-600 dark:text-gray-400 mt-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
<span>{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'}</span>
|
<span>{searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : 'Date not specified'}</span>
|
||||||
@@ -225,26 +332,21 @@ export default function ResultsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{results.map((schedule) => {
|
{results.map((schedule) => {
|
||||||
const scheduleId = schedule.scheduleId || schedule.id || '';
|
const scheduleId = schedule.scheduleId || schedule.id || '';
|
||||||
const isExpanded = expandedSchedules[scheduleId];
|
|
||||||
const selectedClass = selectedClasses[scheduleId];
|
const selectedClass = selectedClasses[scheduleId];
|
||||||
|
|
||||||
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0
|
||||||
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||||
const durationStr = `${hours}h ${minutes}m`;
|
const durationStr = `${hours}h ${minutes}m`;
|
||||||
|
|
||||||
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
|
const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null;
|
||||||
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null;
|
||||||
const isNextDay = departureDate && arrivalDate &&
|
const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString();
|
||||||
departureDate.toDateString() !== arrivalDate.toDateString();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={scheduleId} className="card group">
|
<div key={scheduleId} className="card">
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
|
||||||
|
{/* Train info */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
@@ -255,7 +357,7 @@ export default function ResultsPage() {
|
|||||||
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400">{schedule.trainName || 'Express Service'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
@@ -266,136 +368,62 @@ export default function ResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.origin?.name || 'Origin'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col items-center">
|
<div className="flex-1 flex flex-col items-center">
|
||||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
<span>{durationStr}</span>
|
<span>{durationStr}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
<div className="w-full h-0.5 bg-gray-200 dark:bg-gray-700 relative">
|
||||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full"></div>
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full"></div>
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full" />
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{schedule.stops && schedule.stops.length > 0 && (
|
|
||||||
<>
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
<span>{schedule.stops.length - 2} stops</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{schedule.stops && schedule.stops.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>{schedule.stops.length - 2} stops</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center justify-center gap-1">
|
||||||
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
|
<span>{schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''}</span>
|
||||||
{isNextDay && (
|
{isNextDay && <span className="text-orange-500 font-medium">(+1)</span>}
|
||||||
<span className="text-orange-600 dark:text-orange-400 font-medium">(+1)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{schedule.destination?.name || 'Destination'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fare + action */}
|
||||||
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
<div className="lg:border-l dark:border-gray-700 lg:pl-6 lg:min-w-[220px]">
|
||||||
<div className="text-center lg:text-right">
|
<div className="text-center lg:text-right">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">Starting from</div>
|
||||||
<div className="text-3xl font-bold text-primary dark:text-white">
|
<div className="text-3xl font-bold text-primary">
|
||||||
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
{lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">per adult</div>
|
||||||
|
{selectedClass && (
|
||||||
|
<p className="text-xs text-primary font-semibold mb-2">
|
||||||
|
{selectedClass.replace(/_/g, ' ')} selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleExpanded(scheduleId)}
|
onClick={() => setClassModal(schedule)}
|
||||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<span>Select class</span>
|
{selectedClass ? 'Change class' : 'Select class'}
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="mt-6 pt-6 border-t dark:border-gray-700">
|
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-3">Select Class</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
||||||
{schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 ? (
|
|
||||||
schedule.faresByClass.map((fareClass: any) => {
|
|
||||||
const isSelected = selectedClass === fareClass.seatClassName;
|
|
||||||
const availableSeats = schedule.availabilityByClass?.[fareClass.seatClassName] || 0;
|
|
||||||
const isAvailable = availableSeats > 0;
|
|
||||||
const isBedClass = fareClass.seatClassName.toLowerCase().includes('bed');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={fareClass.seatClassName}
|
|
||||||
onClick={() => isAvailable && handleSelectClass(scheduleId, fareClass.seatClassName)}
|
|
||||||
disabled={!isAvailable}
|
|
||||||
className={`relative p-4 rounded-lg border-2 text-left transition-all ${
|
|
||||||
isSelected
|
|
||||||
? 'border-primary bg-blue-50 dark:bg-blue-900/20 shadow-md'
|
|
||||||
: isAvailable
|
|
||||||
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300 hover:shadow-sm'
|
|
||||||
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-60 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
|
|
||||||
<Check className="w-4 h-4 text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
|
||||||
{fareClass.seatClassName.replace(/_/g, ' ')}
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-primary dark:text-white mb-1">
|
|
||||||
ETB {((fareClass.baseFareMinor || 0) / 100).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{isAvailable ? (
|
|
||||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
|
||||||
{availableSeats} {isBedClass ? 'bed' : 'seat'}{availableSeats !== 1 ? 's' : ''} available
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-red-600 dark:text-red-400 font-medium">Sold out</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="col-span-3 text-center py-4 text-gray-500 dark:text-gray-400">
|
|
||||||
No seat classes available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => handleSelect(schedule)}
|
|
||||||
disabled={!selectedClass}
|
|
||||||
className={`btn-primary flex items-center justify-center gap-2 ${
|
|
||||||
!selectedClass
|
|
||||||
? 'opacity-50 cursor-not-allowed'
|
|
||||||
: 'group-hover:shadow-xl'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>Continue</span>
|
|
||||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -224,6 +224,7 @@ export default function SeatsPage() {
|
|||||||
if (bookingId && selectedSeats.length > 0) {
|
if (bookingId && selectedSeats.length > 0) {
|
||||||
bookSeatsMutation.mutate(selectedSeats);
|
bookSeatsMutation.mutate(selectedSeats);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [bookingId]);
|
}, [bookingId]);
|
||||||
|
|
||||||
const parseSeatArrangement = (arrangement: string | null): number[] => {
|
const parseSeatArrangement = (arrangement: string | null): number[] => {
|
||||||
@@ -394,6 +395,72 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
if (!selectedSchedule || !passengers.length) return null;
|
if (!selectedSchedule || !passengers.length) return null;
|
||||||
|
|
||||||
|
const allSelected = selectedSeats.length === passengers.length;
|
||||||
|
const isBedCoach = selectedCoachData?.seatClass?.toLowerCase().includes('bed') || selectedCoachData?.mode?.toLowerCase().includes('bed');
|
||||||
|
|
||||||
|
// Summary card content — shared between sidebar and mobile modal
|
||||||
|
const SummaryContent = () => (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">Selection Summary</h3>
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
||||||
|
allSelected ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||||
|
}`}>
|
||||||
|
{selectedSeats.length}/{passengers.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
{allSelected ? 'All seats selected — ready to continue' : `Select ${passengers.length - selectedSeats.length} more seat(s)`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full mb-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[rgb(20,113,76)] rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${(selectedSeats.length / passengers.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-5">
|
||||||
|
{passengers.map((p, i) => {
|
||||||
|
const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null;
|
||||||
|
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '—') : '—';
|
||||||
|
const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : '';
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-800 last:border-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||||
|
assignedSeat ? 'bg-[rgb(20,113,76)] text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||||
|
}`}>{i + 1}</div>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300 truncate max-w-[120px]">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-semibold ${
|
||||||
|
assignedSeat ? 'text-[rgb(20,113,76)]' : 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{assignedSeat ? `${seatLabel}${bedLabel}` : 'Not selected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
disabled={selectedSeats.length === 0 || holdMutation.isPending}
|
||||||
|
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
{holdMutation.isPending ? 'Holding seats...' : allSelected ? 'Continue' : 'Continue with partial selection'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoAssign}
|
||||||
|
disabled={holdMutation.isPending}
|
||||||
|
className="w-full mt-2 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-gray-700 dark:text-gray-300 font-semibold text-sm rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
Auto-assign seats
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomModal
|
<CustomModal
|
||||||
@@ -403,166 +470,143 @@ export default function SeatsPage() {
|
|||||||
message={modalState.message}
|
message={modalState.message}
|
||||||
type={modalState.type}
|
type={modalState.type}
|
||||||
/>
|
/>
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
|
||||||
<div className="container mx-auto px-4">
|
{/* Mobile summary bottom-sheet */}
|
||||||
<div className="max-w-6xl mx-auto">
|
{selectedSeats.length > 0 && (
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<>
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-50 lg:hidden bg-white dark:bg-gray-900 rounded-t-2xl shadow-2xl p-5"
|
||||||
|
style={{ animation: 'seats-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full mx-auto mb-4" />
|
||||||
|
<SummaryContent />
|
||||||
|
</div>
|
||||||
|
<style>{`@keyframes seats-slide-up{from{transform:translateY(100%)}to{transform:translateY(0)}}`}</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-30">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="max-w-6xl mx-auto flex items-center justify-between h-14">
|
||||||
<button
|
<button
|
||||||
onClick={handleBackToPassengers}
|
onClick={handleBackToPassengers}
|
||||||
className="flex items-center gap-2 text-[rgb(20_113_76)] hover:text-[rgb(10_80_50)] font-semibold transition-colors"
|
className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-primary transition-colors font-medium"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
Back to passenger details
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
<h1 className="text-base font-bold text-gray-900 dark:text-white">Select Seats</h1>
|
||||||
|
<div className="text-sm font-semibold text-[rgb(20,113,76)]">
|
||||||
|
{selectedSeats.length}/{passengers.length}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Select seats</h1>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="container mx-auto px-4 py-5">
|
||||||
<div className="lg:col-span-2">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
|
|
||||||
|
{/* ── Seat map panel ── */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||||
<p>Loading seats...</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading seat map...</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||||
<div className="text-center py-8 text-red-500 dark:text-red-400">
|
<p className="text-red-500 font-medium">Error loading seats</p>
|
||||||
<p>Error loading seats</p>
|
<p className="text-sm text-gray-400 mt-1">{(error as any)?.message || 'Please try again'}</p>
|
||||||
<p className="text-sm mt-2">{error?.message || 'Please try again'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : filteredCoaches.length === 0 ? (
|
) : filteredCoaches.length === 0 ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 text-center">
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">No coaches available for {selectedSchedule?.selectedSeatClass}</p>
|
||||||
<p>No coaches available for {selectedSchedule?.selectedSeatClass}</p>
|
|
||||||
<p className="text-sm mt-2">Please select a different seat class</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
{/* Coach selector */}
|
||||||
<h3 className="font-semibold mb-3 text-gray-900 dark:text-gray-100">Select coach</h3>
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 border border-gray-100 dark:border-gray-700">
|
||||||
<div className="flex flex-row gap-2">
|
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Coach</h3>
|
||||||
{filteredCoaches?.map((coach: any) => {
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{filteredCoaches.map((coach: any) => {
|
||||||
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
|
const coachSeats = coach.seats?.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')) || [];
|
||||||
const isBedCoach = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
|
const isBed = coach.seatClass?.toLowerCase().includes('bed') || coach.mode?.toLowerCase().includes('bed');
|
||||||
let filteredSeats = coachSeats;
|
let fSeats = coachSeats;
|
||||||
if (isBedCoach && selectedSchedule?.selectedSeatClass) {
|
if (isBed && selectedSchedule?.selectedSeatClass) {
|
||||||
const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
|
const bedPos = getBedPosition(selectedSchedule.selectedSeatClass);
|
||||||
if (bedPos) {
|
if (bedPos) fSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
|
||||||
filteredSeats = coachSeats.filter((s: any) => s.bedPosition === bedPos);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const availableCount = filteredSeats.filter((s: any) => s.status === 'AVAILABLE').length || 0;
|
const available = fSeats.filter((s: any) => s.status === 'AVAILABLE').length;
|
||||||
const seatClassName = selectedSchedule?.selectedSeatClass || (typeof coach.seatClass === 'string' ? coach.seatClass : (coach.seatClass?.name || coach.coachClass || ''));
|
const isActive = selectedCoach === coach.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={coach.id}
|
key={coach.id}
|
||||||
onClick={() => setSelectedCoach(coach.id)}
|
onClick={() => setSelectedCoach(coach.id)}
|
||||||
className={`px-4 py-2 rounded transition-all text-left ${
|
className={`px-4 py-2.5 rounded-xl text-sm font-semibold transition-all ${
|
||||||
selectedCoach === coach.id
|
isActive
|
||||||
? 'bg-[rgb(20_113_76)] text-white shadow-lg'
|
? 'bg-[rgb(20,113,76)] text-white shadow-md'
|
||||||
: 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-semibold">{coach.label || coach.name || coach.coachNumber}</div>
|
<div>{coach.label || coach.name || coach.coachNumber}</div>
|
||||||
<div className="text-xs opacity-75">{seatClassName}</div>
|
<div className={`text-xs mt-0.5 ${isActive ? 'text-white/70' : 'text-gray-400'}`}>{available} free</div>
|
||||||
<div className="text-xs opacity-75">{availableCount} available</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
{/* Seat map */}
|
||||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 border border-gray-100 dark:border-gray-700">
|
||||||
Seat map - {selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
<div className="flex items-center justify-between mb-3">
|
||||||
</h3>
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{selectedCoachData && (
|
{selectedCoachData?.name || selectedCoachData?.label || selectedCoachData?.coachNumber}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
</h3>
|
||||||
Arrangement: {selectedCoachData.seatArrangement} • Total: {selectedCoachData.totalSeats} seats
|
<span className="text-xs text-gray-400">{selectedCoachData?.seatArrangement}</span>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 mb-6 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">Available</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-[rgb(20_113_76)] rounded"></div>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">Selected</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">Held</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 bg-gray-500 rounded"></div>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">Booked</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/30 p-6 rounded-lg overflow-x-auto border border-gray-200 dark:border-gray-700 w-fit">
|
{/* Legend */}
|
||||||
{validSeats.length === 0 ? (
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
{[
|
||||||
<p>No seats available in this coach</p>
|
{ color: 'bg-green-500', label: 'Available' },
|
||||||
<p className="text-sm mt-2">Please select a different coach</p>
|
{ color: 'bg-[rgb(20,113,76)]', label: 'Selected' },
|
||||||
|
{ color: 'bg-yellow-500', label: 'Held' },
|
||||||
|
{ color: 'bg-gray-400', label: 'Booked' },
|
||||||
|
].map(({ color, label }) => (
|
||||||
|
<div key={label} className="flex items-center gap-1.5">
|
||||||
|
<div className={`w-3 h-3 ${color} rounded-sm`} />
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
))}
|
||||||
renderCoachSeats(selectedCoachData, (selectedCoachData.seatClass?.toLowerCase().includes('bed') || selectedCoachData.mode?.toLowerCase().includes('bed')))
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-block bg-gray-50 dark:bg-gray-700/30 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
{validSeats.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 py-4">No seats in this coach</p>
|
||||||
|
) : (
|
||||||
|
renderCoachSeats(selectedCoachData, isBedCoach)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-1">
|
{/* ── Sidebar summary (desktop only) ── */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 sticky top-6">
|
<div className="hidden lg:block lg:col-span-1">
|
||||||
<h3 className="font-semibold mb-4 text-gray-900 dark:text-gray-100">Selection summary</h3>
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-5 border border-gray-100 dark:border-gray-700 sticky top-20">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<SummaryContent />
|
||||||
Select {passengers.length} seat(s) for your passengers
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
|
|
||||||
{selectedSeats.length} / {passengers.length} selected
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-6 max-h-48 overflow-y-auto">
|
|
||||||
{passengers.map((p, i) => {
|
|
||||||
const assignedSeat = selectedSeats[i] ? validSeats?.find((s: any) => s.id === selectedSeats[i]) : null;
|
|
||||||
const seatLabel = assignedSeat ? (assignedSeat.number || assignedSeat.label || assignedSeat.seatNumber || '-') : '-';
|
|
||||||
const bedLabel = assignedSeat ? getBedLabel(assignedSeat.bedPosition) : '';
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex justify-between text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<span>{p.name}</span>
|
|
||||||
<span className="font-medium text-[rgb(20_113_76)]">
|
|
||||||
{seatLabel}{bedLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleContinue}
|
|
||||||
disabled={selectedSeats.length === 0}
|
|
||||||
className="w-full bg-[rgb(20_113_76)] hover:bg-[rgb(10_80_50)] disabled:bg-gray-400 text-white font-semibold py-2 rounded mb-2 transition-colors"
|
|
||||||
>
|
|
||||||
Continue with selected seats
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleAutoAssign}
|
|
||||||
disabled={holdMutation.isPending}
|
|
||||||
className="w-full bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white font-semibold py-2 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{holdMutation.isPending ? 'Assigning...' : 'Auto-assign seats'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile spacer so bottom-sheet doesn't cover last seat */}
|
||||||
|
<div className="h-48 lg:hidden" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,4 +157,22 @@
|
|||||||
.animate-slide-in-right {
|
.animate-slide-in-right {
|
||||||
animation: slide-in-right 0.5s ease-out;
|
animation: slide-in-right 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function LoginContent() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema as any),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: LoginForm) => {
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Facebook, Twitter, Instagram, Linkedin, Mail, Phone, MapPin } from 'lucide-react';
|
import { Mail, Phone, MapPin } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
|
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -102,41 +102,17 @@ export function Footer() {
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
|
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<a
|
<a href="https://web.facebook.com/ethiodjiboutirailwaysc" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Facebook">
|
||||||
href="https://web.facebook.com/ethiodjiboutirailwaysc"
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/></svg>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
|
|
||||||
aria-label="Facebook"
|
|
||||||
>
|
|
||||||
<Facebook className="w-5 h-5" />
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a href="https://twitter.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Twitter">
|
||||||
href="https://twitter.com/edr"
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
|
|
||||||
aria-label="Twitter"
|
|
||||||
>
|
|
||||||
<Twitter className="w-5 h-5" />
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a href="https://instagram.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Instagram">
|
||||||
href="https://instagram.com/edr"
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/></svg>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
|
|
||||||
aria-label="Instagram"
|
|
||||||
>
|
|
||||||
<Instagram className="w-5 h-5" />
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a href="https://linkedin.com/company/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="LinkedIn">
|
||||||
href="https://linkedin.com/company/edr"
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6zM2 9h4v12H2z"/><circle cx="4" cy="4" r="2"/></svg>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
|
|
||||||
aria-label="LinkedIn"
|
|
||||||
>
|
|
||||||
<Linkedin className="w-5 h-5" />
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,16 +28,23 @@ export default function ModernDatePicker({
|
|||||||
placeholder = 'Select date',
|
placeholder = 'Select date',
|
||||||
}: ModernDatePickerProps) {
|
}: ModernDatePickerProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isMobileView, setIsMobileView] = useState(false);
|
||||||
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
|
||||||
const [viewMonth, setViewMonth] = useState(value?.getMonth() || new Date().getMonth());
|
const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth());
|
||||||
const [viewYear, setViewYear] = useState(value?.getFullYear() || new Date().getFullYear());
|
const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear());
|
||||||
|
|
||||||
// Initialize Ethiopian calendar with current date
|
|
||||||
const initialEthDate = gregorianToEthiopian(value || new Date());
|
const initialEthDate = gregorianToEthiopian(value || new Date());
|
||||||
const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month);
|
const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month);
|
||||||
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
|
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setIsMobileView(window.innerWidth < 768);
|
||||||
|
check();
|
||||||
|
window.addEventListener('resize', check);
|
||||||
|
return () => window.removeEventListener('resize', check);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (value) {
|
||||||
setViewMonth(value.getMonth());
|
setViewMonth(value.getMonth());
|
||||||
@@ -48,39 +55,27 @@ export default function ModernDatePicker({
|
|||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
// Lock body scroll when modal is open (both mobile and desktop modal)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const toggleCalendarType = () => {
|
const toggleCalendarType = () => {
|
||||||
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
|
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
|
||||||
|
const ref = value || new Date();
|
||||||
if (newType === 'ethiopian') {
|
if (newType === 'ethiopian') {
|
||||||
// When switching to Ethiopian, show the Ethiopian equivalent of current Gregorian view
|
const ethDate = gregorianToEthiopian(ref);
|
||||||
// Use today's date if no value is selected, otherwise use the selected value
|
|
||||||
const referenceDate = value || new Date();
|
|
||||||
const ethDate = gregorianToEthiopian(referenceDate);
|
|
||||||
setEthViewMonth(ethDate.month);
|
setEthViewMonth(ethDate.month);
|
||||||
setEthViewYear(ethDate.year);
|
setEthViewYear(ethDate.year);
|
||||||
} else {
|
} else {
|
||||||
// When switching to Gregorian, show the Gregorian equivalent of current Ethiopian view
|
setViewMonth(ref.getMonth());
|
||||||
const referenceDate = value || new Date();
|
setViewYear(ref.getFullYear());
|
||||||
setViewMonth(referenceDate.getMonth());
|
|
||||||
setViewYear(referenceDate.getFullYear());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCalendarType(newType);
|
setCalendarType(newType);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,102 +85,46 @@ export default function ModernDatePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEthiopianDateSelect = (ethDate: EthiopianDate) => {
|
const handleEthiopianDateSelect = (ethDate: EthiopianDate) => {
|
||||||
const gregDate = ethiopianToGregorian(ethDate);
|
handleDateSelect(ethiopianToGregorian(ethDate));
|
||||||
handleDateSelect(gregDate);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderGregorianCalendar = () => {
|
const renderGregorianCalendar = () => {
|
||||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
const firstDayOfMonth = new Date(viewYear, viewMonth, 1).getDay();
|
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
|
||||||
const days: (number | null)[] = [];
|
const days: (number | null)[] = Array(firstDay).fill(null);
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) days.push(d);
|
||||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
days.push(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
|
||||||
days.push(day);
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button type="button" onClick={() => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (viewMonth === 0) {
|
|
||||||
setViewMonth(11);
|
|
||||||
setViewYear(viewYear - 1);
|
|
||||||
} else {
|
|
||||||
setViewMonth(viewMonth - 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
<div className="font-semibold text-base text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthNames[viewMonth]} {viewYear}</span>
|
||||||
{monthNames[viewMonth]} {viewYear}
|
<button type="button" onClick={() => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (viewMonth === 11) {
|
|
||||||
setViewMonth(0);
|
|
||||||
setViewYear(viewYear + 1);
|
|
||||||
} else {
|
|
||||||
setViewMonth(viewMonth + 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => (
|
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
|
||||||
<div key={day} className="text-center text-xs font-semibold text-gray-500 dark:text-gray-400 py-2">
|
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{days.map((day, index) => {
|
{days.map((day, i) => {
|
||||||
if (day === null) {
|
if (day === null) return <div key={`e-${i}`} />;
|
||||||
return <div key={`empty-${index}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(viewYear, viewMonth, day);
|
const date = new Date(viewYear, viewMonth, day);
|
||||||
const isSelected = value &&
|
const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
|
||||||
date.getDate() === value.getDate() &&
|
const isToday = date.toDateString() === new Date().toDateString();
|
||||||
date.getMonth() === value.getMonth() &&
|
const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
|
||||||
date.getFullYear() === value.getFullYear();
|
|
||||||
const isToday =
|
|
||||||
date.getDate() === new Date().getDate() &&
|
|
||||||
date.getMonth() === new Date().getMonth() &&
|
|
||||||
date.getFullYear() === new Date().getFullYear();
|
|
||||||
const isDisabled =
|
|
||||||
(minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) ||
|
|
||||||
(maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={day} type="button" onClick={() => !isDisabled && handleDateSelect(date)} disabled={!!isDisabled}
|
||||||
key={day}
|
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||||
type="button"
|
|
||||||
onClick={() => !isDisabled && handleDateSelect(date)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
className={`
|
|
||||||
aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
|
||||||
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
|
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
|
||||||
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
|
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
|
||||||
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
|
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
|
||||||
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}
|
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
|
||||||
`}
|
|
||||||
>
|
|
||||||
{day}
|
{day}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -197,133 +136,54 @@ export default function ModernDatePicker({
|
|||||||
|
|
||||||
const renderEthiopianCalendar = () => {
|
const renderEthiopianCalendar = () => {
|
||||||
const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth);
|
const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth);
|
||||||
const days: number[] = [];
|
|
||||||
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
|
||||||
days.push(day);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 });
|
const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 });
|
||||||
const firstDayOfWeek = firstDate.getDay();
|
const firstDayOfWeek = firstDate.getDay();
|
||||||
|
const daysWithOffset: (number | null)[] = Array(firstDayOfWeek).fill(null);
|
||||||
const daysWithOffset: (number | null)[] = [];
|
for (let d = 1; d <= daysInMonth; d++) daysWithOffset.push(d);
|
||||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
|
||||||
daysWithOffset.push(null);
|
|
||||||
}
|
|
||||||
daysWithOffset.push(...days);
|
|
||||||
|
|
||||||
// Get month name safely
|
|
||||||
const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`;
|
const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button type="button" onClick={() => { if (ethViewMonth === 1) { setEthViewMonth(13); setEthViewYear(y => y - 1); } else setEthViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (ethViewMonth === 1) {
|
|
||||||
setEthViewMonth(13);
|
|
||||||
setEthViewYear(ethViewYear - 1);
|
|
||||||
} else {
|
|
||||||
setEthViewMonth(ethViewMonth - 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
<div className="font-semibold text-base text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthName} {ethViewYear}</span>
|
||||||
{monthName} {ethViewYear}
|
<button type="button" onClick={() => { if (ethViewMonth === 13) { setEthViewMonth(1); setEthViewYear(y => y + 1); } else setEthViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (ethViewMonth === 13) {
|
|
||||||
setEthViewMonth(1);
|
|
||||||
setEthViewYear(ethViewYear + 1);
|
|
||||||
} else {
|
|
||||||
setEthViewMonth(ethViewMonth + 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(day => (
|
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
|
||||||
<div key={day} className="text-center text-xs font-semibold text-gray-500 dark:text-gray-400 py-2">
|
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{daysWithOffset.map((day, index) => {
|
{daysWithOffset.map((day, i) => {
|
||||||
if (day === null) {
|
if (day === null) return <div key={`e-${i}`} />;
|
||||||
return <div key={`empty-${index}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day };
|
const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day };
|
||||||
const gregDate = ethiopianToGregorian(ethDate);
|
const gregDate = ethiopianToGregorian(ethDate);
|
||||||
|
const isSelected = value && gregDate.getDate() === value.getDate() && gregDate.getMonth() === value.getMonth() && gregDate.getFullYear() === value.getFullYear();
|
||||||
const isSelected = value &&
|
const todayEth = gregorianToEthiopian(new Date());
|
||||||
gregDate.getDate() === value.getDate() &&
|
const isToday = ethDate.day === todayEth.day && ethDate.month === todayEth.month && ethDate.year === todayEth.year;
|
||||||
gregDate.getMonth() === value.getMonth() &&
|
|
||||||
gregDate.getFullYear() === value.getFullYear();
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const todayEth = gregorianToEthiopian(today);
|
|
||||||
const isToday =
|
|
||||||
ethDate.day === todayEth.day &&
|
|
||||||
ethDate.month === todayEth.month &&
|
|
||||||
ethDate.year === todayEth.year;
|
|
||||||
|
|
||||||
let isDisabled = false;
|
let isDisabled = false;
|
||||||
if (minDate) {
|
if (minDate) {
|
||||||
const minYear = minDate.getFullYear();
|
const [mY, mM, mD] = [minDate.getFullYear(), minDate.getMonth(), minDate.getDate()];
|
||||||
const minMonth = minDate.getMonth();
|
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
|
||||||
const minDay = minDate.getDate();
|
if (gY < mY || (gY === mY && gM < mM) || (gY === mY && gM === mM && gD < mD)) isDisabled = true;
|
||||||
const gregYear = gregDate.getFullYear();
|
|
||||||
const gregMonth = gregDate.getMonth();
|
|
||||||
const gregDay = gregDate.getDate();
|
|
||||||
|
|
||||||
if (gregYear < minYear ||
|
|
||||||
(gregYear === minYear && gregMonth < minMonth) ||
|
|
||||||
(gregYear === minYear && gregMonth === minMonth && gregDay < minDay)) {
|
|
||||||
isDisabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (maxDate && !isDisabled) {
|
if (maxDate && !isDisabled) {
|
||||||
const maxYear = maxDate.getFullYear();
|
const [mY, mM, mD] = [maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()];
|
||||||
const maxMonth = maxDate.getMonth();
|
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
|
||||||
const maxDay = maxDate.getDate();
|
if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true;
|
||||||
const gregYear = gregDate.getFullYear();
|
|
||||||
const gregMonth = gregDate.getMonth();
|
|
||||||
const gregDay = gregDate.getDate();
|
|
||||||
|
|
||||||
if (gregYear > maxYear ||
|
|
||||||
(gregYear === maxYear && gregMonth > maxMonth) ||
|
|
||||||
(gregYear === maxYear && gregMonth === maxMonth && gregDay > maxDay)) {
|
|
||||||
isDisabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={day} type="button" onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)} disabled={isDisabled}
|
||||||
key={day}
|
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||||
type="button"
|
|
||||||
onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
className={`
|
|
||||||
aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
|
||||||
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
|
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
|
||||||
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
|
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
|
||||||
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
|
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
|
||||||
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}
|
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
|
||||||
`}
|
|
||||||
>
|
|
||||||
{day}
|
{day}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -333,62 +193,128 @@ export default function ModernDatePicker({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const calendarFooter = value && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/60 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Gregorian:</span>
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">{format(value, 'MMMM d, yyyy')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Ethiopian:</span>
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">{formatEthiopianDate(gregorianToEthiopian(value))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shared modal content (used by both mobile and desktop)
|
||||||
|
const modalContent = (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header row: title + close */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-4 h-4 text-primary" />
|
||||||
|
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Date</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle row: always full-width, clearly visible */}
|
||||||
|
<div className="px-4 py-2.5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||||
|
<div className="flex rounded-lg overflow-hidden border-2 border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => calendarType !== 'gregorian' && toggleCalendarType()}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
|
||||||
|
calendarType === 'gregorian'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5" />
|
||||||
|
Gregorian
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => calendarType !== 'ethiopian' && toggleCalendarType()}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
|
||||||
|
calendarType === 'ethiopian'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5" />
|
||||||
|
Ethiopian
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar body */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
|
||||||
|
{calendarFooter}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={containerRef}>
|
<div className="relative" ref={containerRef}>
|
||||||
|
{/* Trigger button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-left flex items-center justify-between bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors group"
|
className="w-full px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all group"
|
||||||
>
|
>
|
||||||
<span className={value ? 'text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400'}>
|
<span className={`text-sm ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
|
||||||
{value ? format(value, 'EEEE, MMMM d, yyyy') : placeholder}
|
{value ? format(value, 'EEE, MMM d, yyyy') : placeholder}
|
||||||
</span>
|
</span>
|
||||||
<CalendarIcon className="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary transition-colors" />
|
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute z-50 mt-2 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 w-80 animate-in fade-in slide-in-from-top-2 duration-200">
|
<>
|
||||||
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4 text-primary" />
|
|
||||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
||||||
{calendarType === 'gregorian' ? 'Gregorian Calendar' : 'Ethiopian Calendar'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleCalendarType}
|
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium bg-primary/10 hover:bg-primary/20 dark:bg-primary/20 dark:hover:bg-primary/30 text-primary rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Globe className="w-3.5 h-3.5" />
|
|
||||||
{calendarType === 'gregorian' ? 'ET' : 'GC'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
|
{/* Mobile: full-screen takeover */}
|
||||||
|
{isMobileView ? (
|
||||||
{value && (
|
<div
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-900/50 space-y-1.5">
|
className="fixed inset-0 z-[100] bg-white dark:bg-gray-900 flex flex-col"
|
||||||
<div className="flex items-center justify-between text-xs">
|
style={{ animation: 'mdp-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
|
||||||
<span className="text-gray-500 dark:text-gray-400">Gregorian:</span>
|
>
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">{format(value, 'MMMM d, yyyy')}</span>
|
{modalContent}
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">Ethiopian:</span>
|
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">{formatEthiopianDate(gregorianToEthiopian(value))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Desktop: centred modal with backdrop */
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[99] bg-black/40 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center p-4 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm pointer-events-auto"
|
||||||
|
style={{ animation: 'mdp-scale-in 0.2s cubic-bezier(0.34,1.56,0.64,1)' }}
|
||||||
|
>
|
||||||
|
{modalContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes mdp-slide-up {
|
||||||
|
from { transform: translateY(100%); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes mdp-scale-in {
|
||||||
|
from { transform: scale(0.92); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +1,93 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface Step {
|
const steps = [
|
||||||
id: string;
|
{ id: 'search', name: 'Search' },
|
||||||
name: string;
|
{ id: 'results', name: 'Results' },
|
||||||
href: string;
|
{ id: 'passengers', name: 'Passengers' },
|
||||||
}
|
{ id: 'seats', name: 'Seats' },
|
||||||
|
{ id: 'review', name: 'Review' },
|
||||||
const steps: Step[] = [
|
{ id: 'payment', name: 'Payment' },
|
||||||
{ id: 'search', name: 'Search', href: '/booking/search' },
|
{ id: 'confirmation', name: 'Done' },
|
||||||
{ id: 'results', name: 'Results', href: '/booking/results' },
|
|
||||||
{ id: 'passengers', name: 'Passengers', href: '/booking/passengers' },
|
|
||||||
{ id: 'seats', name: 'Seats', href: '/booking/seats' },
|
|
||||||
{ id: 'review', name: 'Review', href: '/booking/review' },
|
|
||||||
{ id: 'payment', name: 'Payment', href: '/booking/payment' },
|
|
||||||
{ id: 'confirmation', name: 'Confirmation', href: '/booking/confirmation' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ProgressIndicatorProps {
|
export function ProgressIndicator({ currentStep }: { currentStep: string }) {
|
||||||
currentStep: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
|
|
||||||
const currentIndex = steps.findIndex((s) => s.id === currentStep);
|
const currentIndex = steps.findIndex((s) => s.id === currentStep);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeRef = useRef<HTMLLIElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll active step to centre on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
const active = activeRef.current;
|
||||||
|
if (!container || !active) return;
|
||||||
|
const offset = active.offsetLeft + active.offsetWidth / 2 - container.clientWidth / 2;
|
||||||
|
container.scrollTo({ left: offset, behavior: 'smooth' });
|
||||||
|
}, [currentIndex]);
|
||||||
|
|
||||||
|
const stepItem = (step: typeof steps[0], index: number) => {
|
||||||
|
const isComplete = index < currentIndex;
|
||||||
|
const isCurrent = index === currentIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={step.id}
|
||||||
|
ref={isCurrent ? activeRef : undefined}
|
||||||
|
className="flex flex-col items-center flex-shrink-0 w-[4.5rem] md:flex-1"
|
||||||
|
>
|
||||||
|
{/* Line + Circle row */}
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
{/* Left connector */}
|
||||||
|
<div className={`flex-1 h-0.5 ${index === 0 ? 'invisible' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'}`} />
|
||||||
|
|
||||||
|
{/* Circle */}
|
||||||
|
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-all duration-300 ${
|
||||||
|
isComplete ? 'bg-primary shadow-sm' :
|
||||||
|
isCurrent ? 'border-[3px] border-primary bg-white dark:bg-gray-800 shadow-sm' :
|
||||||
|
'border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
|
||||||
|
}`}>
|
||||||
|
{isComplete
|
||||||
|
? <Check className="h-4 w-4 text-white" />
|
||||||
|
: <span className={`text-xs font-bold ${isCurrent ? 'text-primary' : 'text-gray-400 dark:text-gray-500'}`}>{index + 1}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right connector */}
|
||||||
|
<div className={`flex-1 h-0.5 ${index === steps.length - 1 ? 'invisible' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<p className={`mt-1.5 text-[10px] md:text-xs font-medium text-center leading-tight whitespace-nowrap ${
|
||||||
|
isCurrent ? 'text-primary font-bold' :
|
||||||
|
isComplete ? 'text-gray-600 dark:text-gray-300' :
|
||||||
|
'text-gray-400 dark:text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{step.name}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Progress" className="py-6">
|
<nav aria-label="Progress">
|
||||||
<ol className="flex items-center max-w-6xl mx-auto">
|
{/* Mobile: full-bleed scrollable */}
|
||||||
{steps.map((step, index) => {
|
<div
|
||||||
const isComplete = index < currentIndex;
|
ref={scrollRef}
|
||||||
const isCurrent = index === currentIndex;
|
className="md:hidden overflow-x-auto scrollbar-hide px-4 py-3"
|
||||||
|
>
|
||||||
|
<ol className="flex items-start min-w-full">
|
||||||
|
{steps.map((s, i) => stepItem(s, i))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{/* Desktop: full container width matching header/footer */}
|
||||||
<li key={step.id} className="flex flex-col items-center" style={{ width: `${100 / steps.length}%` }}>
|
<div className="hidden md:block container mx-auto px-4 py-4">
|
||||||
<div className="flex items-center w-full">
|
<ol className="flex items-start max-w-6xl mx-auto">
|
||||||
<div
|
{steps.map((s, i) => stepItem(s, i))}
|
||||||
className={`flex-1 h-1 transition-all duration-300 ${
|
</ol>
|
||||||
index === 0 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
</div>
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`relative flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-all duration-300 ${
|
|
||||||
isComplete
|
|
||||||
? 'bg-primary shadow-lg scale-110'
|
|
||||||
: isCurrent
|
|
||||||
? 'border-4 border-primary bg-white dark:bg-gray-800 shadow-lg scale-110'
|
|
||||||
: 'border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isComplete ? (
|
|
||||||
<Check className="h-5 w-5 text-white" />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`text-sm font-bold ${
|
|
||||||
isCurrent ? 'text-primary' : 'text-gray-400 dark:text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`flex-1 h-1 transition-all duration-300 ${
|
|
||||||
index === steps.length - 1 ? 'opacity-0' : isComplete ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
<div className={`flex-1 ${index === 0 ? 'opacity-0' : ''}`} />
|
|
||||||
<p
|
|
||||||
className={`mt-3 text-xs md:text-sm font-medium transition-colors flex-shrink-0 ${
|
|
||||||
isCurrent ? 'text-primary font-bold' : isComplete ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step.name}
|
|
||||||
</p>
|
|
||||||
<div className={`flex-1 ${index === steps.length - 1 ? 'opacity-0' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
|
||||||
resolver: zodResolver(searchSchema),
|
resolver: zodResolver(searchSchema as any),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
adultCount: 1,
|
adultCount: 1,
|
||||||
childCount: 0,
|
childCount: 0,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface SearchCriteria {
|
|||||||
adultCount: number;
|
adultCount: number;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
|
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER';
|
||||||
|
promoCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PassengerDetail {
|
export interface PassengerDetail {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
41
apps/edr-payment-api/Dockerfile
Normal file
41
apps/edr-payment-api/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
|
||||||
|
|
||||||
|
FROM node:24.15.0-alpine AS base
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS pruner
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm dlx turbo prune "@edr/payment-api" --docker
|
||||||
|
|
||||||
|
FROM base AS installer
|
||||||
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=installer /app/ .
|
||||||
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
RUN pnpm turbo build --filter="@edr/payment-api..."
|
||||||
|
|
||||||
|
FROM base AS deployer
|
||||||
|
COPY --from=builder /app/ .
|
||||||
|
RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy --ignore-scripts /deploy
|
||||||
|
|
||||||
|
FROM node:24.15.0-alpine AS runner
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||||
|
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||||
|
COPY apps/edr-payment-api/docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh \
|
||||||
|
&& chown -R nestjs:nodejs /app
|
||||||
|
USER nestjs
|
||||||
|
EXPOSE 3008
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
9
apps/edr-payment-api/docker-entrypoint.sh
Normal file
9
apps/edr-payment-api/docker-entrypoint.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /app
|
||||||
|
|
||||||
|
# npm run executes the same package.json scripts as pnpm run (pnpm reinstalls in deploy layout)
|
||||||
|
node dist/scripts/migrate.js
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
8
apps/edr-payment-api/nest-cli.json
Normal file
8
apps/edr-payment-api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": false
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/edr-payment-api/package.json
Normal file
74
apps/edr-payment-api/package.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "@edr/payment-api",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "EDR Payment Microservice — owns provider integration, payment intents, webhooks, and outbox notifications for the whole platform",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||||
|
"predev": "pnpm run clean",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"prebuild": "pnpm run clean",
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"test": "jest",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"migration:run": "node dist/scripts/migrate.js",
|
||||||
|
"migration:revert": "ts-node src/scripts/migrate-revert.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@edr/api-common": "workspace:*",
|
||||||
|
"@edr/payment-providers": "workspace:*",
|
||||||
|
"@edr/types": "workspace:*",
|
||||||
|
"@nestjs/axios": "^4.0.1",
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/config": "^4.0.0",
|
||||||
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/schedule": "^6.0.0",
|
||||||
|
"@nestjs/swagger": "^11.4.2",
|
||||||
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"axios": "^1.16.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "0.3.30"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@edr/eslint-config": "workspace:*",
|
||||||
|
"@edr/tsconfig": "workspace:*",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.13",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/pg": "^8.6.7",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/edr-payment-api/src/app.module.ts
Normal file
51
apps/edr-payment-api/src/app.module.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
|
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
|
import appConfig from "./config/app.config";
|
||||||
|
import databaseConfig from "./config/database.config";
|
||||||
|
import notifierConfig from "./config/notifier.config";
|
||||||
|
import telebirrConfig from "./config/telebirr.config";
|
||||||
|
import waafiConfig from "./config/waafi.config";
|
||||||
|
import cbeConfig from "./config/cbe.config";
|
||||||
|
import ebirrConfig from "./config/ebirr.config";
|
||||||
|
import cardConfig from "./config/card.config";
|
||||||
|
import dmoneyConfig from "./config/dmoney.config";
|
||||||
|
import { HealthModule } from "./modules/health/health.module";
|
||||||
|
import { IntentsModule } from "./modules/intents/intents.module";
|
||||||
|
import { OutboxModule } from "./modules/outbox/outbox.module";
|
||||||
|
import { ProvidersModule } from "./modules/providers/providers.module";
|
||||||
|
import { ReconciliationModule } from "./modules/reconciliation/reconciliation.module";
|
||||||
|
import { WebhooksModule } from "./modules/webhooks/webhooks.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [
|
||||||
|
appConfig,
|
||||||
|
databaseConfig,
|
||||||
|
notifierConfig,
|
||||||
|
telebirrConfig,
|
||||||
|
waafiConfig,
|
||||||
|
cbeConfig,
|
||||||
|
ebirrConfig,
|
||||||
|
cardConfig,
|
||||||
|
dmoneyConfig,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) =>
|
||||||
|
config.get<TypeOrmModuleOptions>("database") as TypeOrmModuleOptions,
|
||||||
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
HealthModule,
|
||||||
|
ProvidersModule,
|
||||||
|
IntentsModule,
|
||||||
|
WebhooksModule,
|
||||||
|
OutboxModule,
|
||||||
|
ReconciliationModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
55
apps/edr-payment-api/src/common/guards/service-auth.guard.ts
Normal file
55
apps/edr-payment-api/src/common/guards/service-auth.guard.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared-secret service-to-service auth for the internal surface (/payments/*).
|
||||||
|
* Callers send `x-service-token: <SERVICE_AUTH_TOKEN>` (or `Authorization: Bearer …`).
|
||||||
|
* Webhook endpoints are intentionally NOT behind this guard — they are provider-facing and
|
||||||
|
* authenticate via signature verification instead.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ServiceAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(ServiceAuthGuard.name);
|
||||||
|
private readonly token: string;
|
||||||
|
private warned = false;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
this.token = config.get<string>("app.serviceAuthToken") ?? "";
|
||||||
|
if (!this.token && process.env.NODE_ENV === "production") {
|
||||||
|
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
if (!this.token) {
|
||||||
|
if (!this.warned) {
|
||||||
|
this.logger.warn(
|
||||||
|
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
|
||||||
|
);
|
||||||
|
this.warned = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const header = request.headers["x-service-token"];
|
||||||
|
const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, "");
|
||||||
|
const presented =
|
||||||
|
(Array.isArray(header) ? header[0] : header) ?? bearer ?? "";
|
||||||
|
|
||||||
|
const expected = Buffer.from(this.token);
|
||||||
|
const actual = Buffer.from(presented);
|
||||||
|
const valid =
|
||||||
|
expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||||
|
if (!valid) throw new UnauthorizedException("Invalid service token");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/edr-payment-api/src/config/app.config.ts
Normal file
21
apps/edr-payment-api/src/config/app.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("app", () => ({
|
||||||
|
port: parseInt(process.env.PORT ?? "3003", 10),
|
||||||
|
/**
|
||||||
|
* Shared secret for service-to-service auth (apps -> /payments/*, payment -> mark-paid).
|
||||||
|
* Required in production; in development an empty value disables the guard with a warning.
|
||||||
|
* TODO: integrate @tria-plc IAM / mTLS as the long-term mechanism (docs/payment-service §14).
|
||||||
|
*/
|
||||||
|
serviceAuthToken: process.env.SERVICE_AUTH_TOKEN ?? "",
|
||||||
|
reconciliation: {
|
||||||
|
/** How often the stale-intent sweep runs. */
|
||||||
|
sweepIntervalMs: parseInt(
|
||||||
|
process.env.RECONCILE_SWEEP_INTERVAL_MS ?? "60000",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
/** An intent is "stale" when non-terminal and untouched for this long. */
|
||||||
|
staleAfterMs: parseInt(process.env.RECONCILE_STALE_AFTER_MS ?? "60000", 10),
|
||||||
|
batchSize: parseInt(process.env.RECONCILE_BATCH_SIZE ?? "20", 10),
|
||||||
|
},
|
||||||
|
}));
|
||||||
9
apps/edr-payment-api/src/config/card.config.ts
Normal file
9
apps/edr-payment-api/src/config/card.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("card", () => ({
|
||||||
|
baseUrl: process.env.CARD_BASE_URL || "",
|
||||||
|
apiKey: process.env.CARD_API_KEY || "",
|
||||||
|
webhookSecret: process.env.CARD_WEBHOOK_SECRET || "",
|
||||||
|
webhookUrl: process.env.CARD_WEBHOOK_URL || "",
|
||||||
|
returnUrl: process.env.CARD_RETURN_URL || "",
|
||||||
|
}));
|
||||||
9
apps/edr-payment-api/src/config/cbe.config.ts
Normal file
9
apps/edr-payment-api/src/config/cbe.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("cbe", () => ({
|
||||||
|
baseUrl: process.env.CBE_BASE_URL || "",
|
||||||
|
merchantId: process.env.CBE_MERCHANT_ID || "",
|
||||||
|
secretKey: process.env.CBE_SECRET_KEY || "",
|
||||||
|
notifyUrl: process.env.CBE_NOTIFY_URL || "",
|
||||||
|
returnUrl: process.env.CBE_RETURN_URL || "",
|
||||||
|
}));
|
||||||
36
apps/edr-payment-api/src/config/database.config.ts
Normal file
36
apps/edr-payment-api/src/config/database.config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
|
import { DataSourceOptions } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared connection options for the Nest TypeORM module and the standalone DataSource
|
||||||
|
* (migration CLI). Payment tables live in the SAME Postgres database as the domain system
|
||||||
|
* (edr_database by default) but in the dedicated `edr_payment` schema; logical ownership is
|
||||||
|
* enforced with a dedicated DB user in non-dev environments (grants only on this schema).
|
||||||
|
*/
|
||||||
|
export function buildDataSourceOptions(): DataSourceOptions {
|
||||||
|
return {
|
||||||
|
type: "postgres",
|
||||||
|
host: process.env.DB_HOST ?? "localhost",
|
||||||
|
port: parseInt(process.env.DB_PORT ?? "5432", 10),
|
||||||
|
username: process.env.DB_USER ?? "edr",
|
||||||
|
password: process.env.DB_PASSWORD ?? "",
|
||||||
|
database: process.env.DB_NAME ?? "edr_database",
|
||||||
|
schema: process.env.DB_SCHEMA ?? "edr_payment",
|
||||||
|
entities: [__dirname + "/../**/*.entity.{ts,js}"],
|
||||||
|
migrations: [__dirname + "/../migrations/*.{ts,js}"],
|
||||||
|
// Schema changes go through migrations only — never synchronize (house rule).
|
||||||
|
synchronize: false,
|
||||||
|
logging: process.env.NODE_ENV === "development",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default registerAs(
|
||||||
|
"database",
|
||||||
|
(): TypeOrmModuleOptions => ({
|
||||||
|
...buildDataSourceOptions(),
|
||||||
|
autoLoadEntities: true,
|
||||||
|
// Run pending migrations on boot (main.ts ensures the database/schema exist first).
|
||||||
|
migrationsRun: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
10
apps/edr-payment-api/src/config/dmoney.config.ts
Normal file
10
apps/edr-payment-api/src/config/dmoney.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("dmoney", () => ({
|
||||||
|
baseUrl: process.env.DMONEY_BASE_URL ?? "",
|
||||||
|
appId: process.env.DMONEY_APP_ID ?? "",
|
||||||
|
appSecret: process.env.DMONEY_APP_SECRET ?? "",
|
||||||
|
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
|
||||||
|
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
|
||||||
|
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "",
|
||||||
|
}));
|
||||||
9
apps/edr-payment-api/src/config/ebirr.config.ts
Normal file
9
apps/edr-payment-api/src/config/ebirr.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("ebirr", () => ({
|
||||||
|
baseUrl: process.env.EBIRR_BASE_URL || "",
|
||||||
|
merchantCode: process.env.EBIRR_MERCHANT_CODE || "",
|
||||||
|
secretKey: process.env.EBIRR_SECRET_KEY || "",
|
||||||
|
notifyUrl: process.env.EBIRR_NOTIFY_URL || "",
|
||||||
|
returnUrl: process.env.EBIRR_RETURN_URL || "",
|
||||||
|
}));
|
||||||
52
apps/edr-payment-api/src/config/ensure-schema.ts
Normal file
52
apps/edr-payment-api/src/config/ensure-schema.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Client } from "pg";
|
||||||
|
|
||||||
|
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
||||||
|
|
||||||
|
function connectionEnv() {
|
||||||
|
return {
|
||||||
|
host: process.env.DB_HOST ?? "localhost",
|
||||||
|
port: parseInt(process.env.DB_PORT ?? "5432", 10),
|
||||||
|
user: process.env.DB_USER ?? "edr",
|
||||||
|
password: process.env.DB_PASSWORD ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev/bootstrap convenience: make sure the `edr_payment` schema exists in the shared
|
||||||
|
* database before TypeORM initializes (the migrations table itself lives in the schema, so
|
||||||
|
* migrations cannot create it). In production the schema/grants are provisioned out-of-band
|
||||||
|
* by ops; this is then a no-op.
|
||||||
|
*/
|
||||||
|
export async function ensurePaymentSchema(): Promise<void> {
|
||||||
|
const database = process.env.DB_NAME ?? "edr_database";
|
||||||
|
const schema = process.env.DB_SCHEMA ?? "edr_payment";
|
||||||
|
if (!IDENTIFIER.test(database) || !IDENTIFIER.test(schema)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid DB_NAME/DB_SCHEMA identifier: ${database}/${schema}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = new Client({ ...connectionEnv(), database });
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
} catch (err) {
|
||||||
|
// 3D000 = database does not exist — create it from the maintenance DB, then reconnect.
|
||||||
|
if ((err as { code?: string }).code !== "3D000") throw err;
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
const admin = new Client({ ...connectionEnv(), database: "postgres" });
|
||||||
|
await admin.connect();
|
||||||
|
try {
|
||||||
|
await admin.query(`CREATE DATABASE "${database}"`);
|
||||||
|
} finally {
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
|
client = new Client({ ...connectionEnv(), database });
|
||||||
|
await client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/edr-payment-api/src/config/notifier.config.ts
Normal file
15
apps/edr-payment-api/src/config/notifier.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("notifier", () => ({
|
||||||
|
/** mark-paid callback URL per owning service (PaymentService discriminator routes here). */
|
||||||
|
passengerUrl:
|
||||||
|
process.env.PAYMENT_NOTIFY_PASSENGER_URL ??
|
||||||
|
"http://localhost:3002/internal/payments/mark-paid",
|
||||||
|
freightUrl:
|
||||||
|
process.env.PAYMENT_NOTIFY_FREIGHT_URL ??
|
||||||
|
"http://localhost:3001/internal/payments/mark-paid",
|
||||||
|
relayIntervalMs: parseInt(process.env.OUTBOX_RELAY_INTERVAL_MS ?? "5000", 10),
|
||||||
|
maxAttempts: parseInt(process.env.OUTBOX_MAX_ATTEMPTS ?? "10", 10),
|
||||||
|
httpTimeoutMs: parseInt(process.env.NOTIFY_HTTP_TIMEOUT_MS ?? "10000", 10),
|
||||||
|
relayBatchSize: parseInt(process.env.OUTBOX_RELAY_BATCH_SIZE ?? "20", 10),
|
||||||
|
}));
|
||||||
16
apps/edr-payment-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-payment-api/src/config/telebirr.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("telebirr", () => ({
|
||||||
|
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
|
||||||
|
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
|
||||||
|
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
|
||||||
|
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
|
||||||
|
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
|
||||||
|
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
|
||||||
|
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
|
||||||
|
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
|
||||||
|
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
|
||||||
|
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
|
||||||
|
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
|
||||||
|
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
|
||||||
|
}));
|
||||||
27
apps/edr-payment-api/src/config/waafi.config.ts
Normal file
27
apps/edr-payment-api/src/config/waafi.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("waafi", () => ({
|
||||||
|
// `/asm` is appended in the provider; use sandbox by default, switch to
|
||||||
|
// https://api.waafipay.net in production.
|
||||||
|
baseUrl: process.env.WAAFI_BASE_URL ?? "https://sandbox.waafipay.net",
|
||||||
|
// HPP credentials (Hosted Payment Page family).
|
||||||
|
merchantUid: process.env.WAAFI_MERCHANT_UID ?? "",
|
||||||
|
storeId: process.env.WAAFI_STORE_ID ?? "",
|
||||||
|
hppKey: process.env.WAAFI_HPP_KEY ?? "",
|
||||||
|
// HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks.
|
||||||
|
webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? "",
|
||||||
|
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
|
||||||
|
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
|
||||||
|
// Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH).
|
||||||
|
currency: process.env.WAAFI_CURRENCY ?? "DJF",
|
||||||
|
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
|
||||||
|
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
|
||||||
|
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",
|
||||||
|
// Callback data format: 1 = POST, 2 = GET, 4 = Result Token.
|
||||||
|
respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? "1"),
|
||||||
|
// Registered webhook URL (reference only; registration is performed out-of-band).
|
||||||
|
notifyUrl: process.env.WAAFI_NOTIFY_URL ?? "",
|
||||||
|
// DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that
|
||||||
|
// does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod.
|
||||||
|
insecureTls: process.env.WAAFI_INSECURE_TLS === "true",
|
||||||
|
}));
|
||||||
8
apps/edr-payment-api/src/data-source.ts
Normal file
8
apps/edr-payment-api/src/data-source.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
import { buildDataSourceOptions } from "./config/database.config";
|
||||||
|
|
||||||
|
/** Standalone DataSource for the TypeORM CLI and the migrate script. */
|
||||||
|
export const AppDataSource = new DataSource(buildDataSourceOptions());
|
||||||
|
|
||||||
|
export default AppDataSource;
|
||||||
54
apps/edr-payment-api/src/main.ts
Normal file
54
apps/edr-payment-api/src/main.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import "reflect-metadata";
|
||||||
|
import "dotenv/config";
|
||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import { ValidationPipe } from "@nestjs/common";
|
||||||
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
|
import { AppModule } from "./app.module";
|
||||||
|
import { ensurePaymentSchema } from "./config/ensure-schema";
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
// The edr_payment database/schema must exist before TypeORM boots (migrationsRun: true).
|
||||||
|
await ensurePaymentSchema();
|
||||||
|
|
||||||
|
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
|
||||||
|
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||||
|
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidUnknownValues: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle("EDR Payment API")
|
||||||
|
.setDescription(
|
||||||
|
"Platform payment microservice: payment intents, provider integration, the single " +
|
||||||
|
"registered webhook per provider, and reliable (outbox) notification of the owning app. " +
|
||||||
|
"Internal endpoints (/payments/*) require the x-service-token header; /webhooks/* is the " +
|
||||||
|
"only public surface. See docs/payment-service/.",
|
||||||
|
)
|
||||||
|
.setVersion("1.0.0")
|
||||||
|
.addApiKey(
|
||||||
|
{ type: "apiKey", name: "x-service-token", in: "header" },
|
||||||
|
"service-token",
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
SwaggerModule.setup(
|
||||||
|
"api-docs",
|
||||||
|
app,
|
||||||
|
SwaggerModule.createDocument(app, config),
|
||||||
|
{
|
||||||
|
customSiteTitle: "EDR Payment API",
|
||||||
|
swaggerOptions: { persistAuthorization: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const port = process.env.PORT ?? 3003;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`🚀 EDR Payment API running on port ${port}`);
|
||||||
|
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial edr_payment schema: payment_intent, payment_webhook_event, notification_outbox.
|
||||||
|
*
|
||||||
|
* Enum-valued columns are varchar on purpose (values mirror the @edr/types enums) so new
|
||||||
|
* providers/statuses never need an ALTER TYPE. uuid defaults use gen_random_uuid() (built into
|
||||||
|
* Postgres 13+; no extension required).
|
||||||
|
*/
|
||||||
|
export class InitPaymentSchema1781136000000 implements MigrationInterface {
|
||||||
|
name = "InitPaymentSchema1781136000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Defensive — bootstrap (ensure-schema) normally creates this before migrations run.
|
||||||
|
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "edr_payment"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "edr_payment"."payment_intent" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
"service" varchar(16) NOT NULL,
|
||||||
|
"reference_type" varchar(16) NOT NULL,
|
||||||
|
"reference_id" varchar(64) NOT NULL,
|
||||||
|
"merchant_order_id" varchar(64) NOT NULL,
|
||||||
|
"provider" varchar(16) NOT NULL,
|
||||||
|
"provider_order_id" varchar(128),
|
||||||
|
"provider_txn_id" varchar(128),
|
||||||
|
"amount_minor" integer NOT NULL,
|
||||||
|
"confirmed_amount_minor" integer,
|
||||||
|
"currency" varchar(8) NOT NULL,
|
||||||
|
"status" varchar(24) NOT NULL DEFAULT 'REQUIRES_ACTION',
|
||||||
|
"client_action" jsonb,
|
||||||
|
"failure_code" varchar(64),
|
||||||
|
"failure_message" text,
|
||||||
|
"idempotency_key" varchar(128),
|
||||||
|
"expires_at" timestamptz,
|
||||||
|
"paid_at" timestamptz,
|
||||||
|
"raw_initiation" jsonb,
|
||||||
|
CONSTRAINT "pk_payment_intent" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "uq_payment_intent_merchant_order_id" UNIQUE ("merchant_order_id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
// One ACTIVE intent per domain order; terminal-failed attempts remain as audit rows.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX "uq_payment_intent_active_reference"
|
||||||
|
ON "edr_payment"."payment_intent" ("service", "reference_type", "reference_id")
|
||||||
|
WHERE status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX "idx_payment_intent_provider_txn"
|
||||||
|
ON "edr_payment"."payment_intent" ("provider_txn_id")
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX "idx_payment_intent_sweep"
|
||||||
|
ON "edr_payment"."payment_intent" ("status", "updated_at")
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX "idx_payment_intent_idempotency"
|
||||||
|
ON "edr_payment"."payment_intent" ("service", "idempotency_key")
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "edr_payment"."payment_webhook_event" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
"provider" varchar(16) NOT NULL,
|
||||||
|
"external_event_id" varchar(191) NOT NULL,
|
||||||
|
"merchant_order_id" varchar(64),
|
||||||
|
"provider_txn_id" varchar(128),
|
||||||
|
"signature_valid" boolean NOT NULL DEFAULT false,
|
||||||
|
"status" varchar(64),
|
||||||
|
"payload" jsonb NOT NULL,
|
||||||
|
"received_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"processed_at" timestamptz,
|
||||||
|
"processing_error" text,
|
||||||
|
CONSTRAINT "pk_payment_webhook_event" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
// The webhook dedupe key: duplicate provider deliveries hit this and short-circuit.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX "uq_payment_webhook_event_external"
|
||||||
|
ON "edr_payment"."payment_webhook_event" ("provider", "external_event_id")
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "edr_payment"."notification_outbox" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
"event_type" varchar(32) NOT NULL,
|
||||||
|
"service" varchar(16) NOT NULL,
|
||||||
|
"intent_id" uuid NOT NULL,
|
||||||
|
"reference_type" varchar(16) NOT NULL,
|
||||||
|
"reference_id" varchar(64) NOT NULL,
|
||||||
|
"payload" jsonb NOT NULL,
|
||||||
|
"status" varchar(16) NOT NULL DEFAULT 'PENDING',
|
||||||
|
"attempts" integer NOT NULL DEFAULT 0,
|
||||||
|
"next_retry_at" timestamptz,
|
||||||
|
"last_error" text,
|
||||||
|
"sent_at" timestamptz,
|
||||||
|
CONSTRAINT "pk_notification_outbox" PRIMARY KEY ("id")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX "idx_notification_outbox_relay"
|
||||||
|
ON "edr_payment"."notification_outbox" ("status", "next_retry_at")
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX "idx_notification_outbox_intent"
|
||||||
|
ON "edr_payment"."notification_outbox" ("intent_id")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "edr_payment"."notification_outbox"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "edr_payment"."payment_webhook_event"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "edr_payment"."payment_intent"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/edr-payment-api/src/modules/health/health.controller.ts
Normal file
16
apps/edr-payment-api/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Get } from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
@ApiTags("Health")
|
||||||
|
@Controller("health")
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: "Liveness probe" })
|
||||||
|
check() {
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
service: "edr-payment-api",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/edr-payment-api/src/modules/health/health.module.ts
Normal file
7
apps/edr-payment-api/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { HealthController } from "./health.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
InitiatePaymentRequest,
|
||||||
|
PaymentPlatform,
|
||||||
|
PaymentReferenceType,
|
||||||
|
PaymentService,
|
||||||
|
ProviderMethod,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
/** Wire shape is the shared `InitiatePaymentRequest` contract from @edr/types. */
|
||||||
|
export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
|
||||||
|
@ApiProperty({ enum: PaymentService })
|
||||||
|
@IsEnum(PaymentService)
|
||||||
|
service!: PaymentService;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: PaymentReferenceType })
|
||||||
|
@IsEnum(PaymentReferenceType)
|
||||||
|
referenceType!: PaymentReferenceType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Domain order id (booking/shipment id) — already validated by the calling app",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 64)
|
||||||
|
referenceId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Human-readable order ref shown on provider pages; defaults to referenceId",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
orderRef?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Authoritative amount in minor units, computed server-side by the app",
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
amountMinor!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "ETB" })
|
||||||
|
@IsString()
|
||||||
|
@Length(3, 8)
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ProviderMethod })
|
||||||
|
@IsEnum(ProviderMethod)
|
||||||
|
provider!: ProviderMethod;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ["web", "mobile"] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["web", "mobile"])
|
||||||
|
platform?: PaymentPlatform;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Payer wallet MSISDN for providers that pre-fill it (e.g. Waafi MWALLET_ACCOUNT)",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
payerAccount?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"Per-transaction browser return URL on success — each calling app passes its own UI " +
|
||||||
|
"(passenger portal vs freight portal). UX only; never confirms payment. Falls back to " +
|
||||||
|
"the provider config when omitted.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
|
returnUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Failure/cancel counterpart of returnUrl",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
|
failureUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Caller key to dedupe retried initiations",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IntentReferenceQueryDto {
|
||||||
|
@ApiProperty({ enum: PaymentService })
|
||||||
|
@IsEnum(PaymentService)
|
||||||
|
service!: PaymentService;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: PaymentReferenceType })
|
||||||
|
@IsEnum(PaymentReferenceType)
|
||||||
|
referenceType!: PaymentReferenceType;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 64)
|
||||||
|
referenceId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import {
|
||||||
|
ClientAction,
|
||||||
|
PaymentReferenceType,
|
||||||
|
PaymentService,
|
||||||
|
ProviderMethod,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One payment attempt for one domain order — the platform-wide source of truth for payment
|
||||||
|
* state. `reference_id` is a soft reference into the owning app's schema (never a FK; see
|
||||||
|
* docs/payment-service/architecture.md §5).
|
||||||
|
*
|
||||||
|
* Enum-valued columns are stored as varchar (values mirror the shared @edr/types enums) so
|
||||||
|
* adding a provider/status never needs an ALTER TYPE migration.
|
||||||
|
*/
|
||||||
|
@Entity({ name: "payment_intent" })
|
||||||
|
// One ACTIVE intent per domain order; FAILED/CANCELLED attempts may accumulate as audit rows.
|
||||||
|
@Index(
|
||||||
|
"uq_payment_intent_active_reference",
|
||||||
|
["service", "referenceType", "referenceId"],
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
where: `status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@Index("idx_payment_intent_sweep", ["status", "updatedAt"])
|
||||||
|
@Index("idx_payment_intent_idempotency", ["service", "idempotencyKey"])
|
||||||
|
export class PaymentIntent extends BaseEntity {
|
||||||
|
/** Owning domain app — routing discriminator for notifications. */
|
||||||
|
@Column({ name: "service", type: "varchar", length: 16 })
|
||||||
|
service!: PaymentService;
|
||||||
|
|
||||||
|
@Column({ name: "reference_type", type: "varchar", length: 16 })
|
||||||
|
referenceType!: PaymentReferenceType;
|
||||||
|
|
||||||
|
/** Domain order id (booking/shipment). Soft reference — no cross-schema FK. */
|
||||||
|
@Column({ name: "reference_id", type: "varchar", length: 64 })
|
||||||
|
referenceId!: string;
|
||||||
|
|
||||||
|
/** Provider-facing reference, prefixed PSG-/FRT- so webhooks route before a DB lookup. */
|
||||||
|
@Column({
|
||||||
|
name: "merchant_order_id",
|
||||||
|
type: "varchar",
|
||||||
|
length: 64,
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
merchantOrderId!: string;
|
||||||
|
|
||||||
|
@Column({ name: "provider", type: "varchar", length: 16 })
|
||||||
|
provider!: ProviderMethod;
|
||||||
|
|
||||||
|
/** Provider-side order/session id (prepay id, HPP orderId, …). */
|
||||||
|
@Column({
|
||||||
|
name: "provider_order_id",
|
||||||
|
type: "varchar",
|
||||||
|
length: 128,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
providerOrderId?: string | null;
|
||||||
|
|
||||||
|
/** Final provider transaction id, set on terminal success. */
|
||||||
|
@Index("idx_payment_intent_provider_txn")
|
||||||
|
@Column({
|
||||||
|
name: "provider_txn_id",
|
||||||
|
type: "varchar",
|
||||||
|
length: 128,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
providerTxnId?: string | null;
|
||||||
|
|
||||||
|
/** App-asserted authoritative amount in minor units. */
|
||||||
|
@Column({ name: "amount_minor", type: "integer" })
|
||||||
|
amountMinor!: number;
|
||||||
|
|
||||||
|
/** Provider-reported amount; reconciled against amount_minor (e.g. Waafi truncates decimals). */
|
||||||
|
@Column({ name: "confirmed_amount_minor", type: "integer", nullable: true })
|
||||||
|
confirmedAmountMinor?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: "currency", type: "varchar", length: 8 })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
/** State machine: REQUIRES_ACTION → PROCESSING → SUCCEEDED | FAILED | CANCELLED (absorbing). */
|
||||||
|
@Column({
|
||||||
|
name: "status",
|
||||||
|
type: "varchar",
|
||||||
|
length: 24,
|
||||||
|
default: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
|
})
|
||||||
|
status!: ProviderPaymentStatus;
|
||||||
|
|
||||||
|
/** Redirect/launch payload returned to the app for the user to complete payment. */
|
||||||
|
@Column({ name: "client_action", type: "jsonb", nullable: true })
|
||||||
|
clientAction?: ClientAction | null;
|
||||||
|
|
||||||
|
@Column({ name: "failure_code", type: "varchar", length: 64, nullable: true })
|
||||||
|
failureCode?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "failure_message", type: "text", nullable: true })
|
||||||
|
failureMessage?: string | null;
|
||||||
|
|
||||||
|
/** Caller-supplied initiate dedupe key (in addition to the per-reference upsert). */
|
||||||
|
@Column({
|
||||||
|
name: "idempotency_key",
|
||||||
|
type: "varchar",
|
||||||
|
length: 128,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
idempotencyKey?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "expires_at", type: "timestamptz", nullable: true })
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
|
||||||
|
paidAt?: Date | null;
|
||||||
|
|
||||||
|
/** Audit copy of the provider initiation request/response (secrets redacted upstream). */
|
||||||
|
@Column({ name: "raw_initiation", type: "jsonb", nullable: true })
|
||||||
|
rawInitiation?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statuses that keep the per-reference unique index "active" (block a new intent). */
|
||||||
|
export const ACTIVE_INTENT_STATUSES = [
|
||||||
|
ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
|
ProviderPaymentStatus.PROCESSING,
|
||||||
|
ProviderPaymentStatus.SUCCEEDED,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const TERMINAL_INTENT_STATUSES = [
|
||||||
|
ProviderPaymentStatus.SUCCEEDED,
|
||||||
|
ProviderPaymentStatus.FAILED,
|
||||||
|
ProviderPaymentStatus.CANCELLED,
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { PaymentIntentSnapshot } from "@edr/types";
|
||||||
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
|
import {
|
||||||
|
InitiatePaymentRequestDto,
|
||||||
|
IntentReferenceQueryDto,
|
||||||
|
} from "./dto/initiate-payment.dto";
|
||||||
|
import { IntentsService } from "./intents.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal surface — called only by the domain apps (service-authenticated), never by
|
||||||
|
* browsers. Domain validation ("is this booking payable", authoritative amount) has already
|
||||||
|
* happened in the calling app.
|
||||||
|
*/
|
||||||
|
@ApiTags("Payments (internal)")
|
||||||
|
@UseGuards(ServiceAuthGuard)
|
||||||
|
@Controller("payments")
|
||||||
|
export class IntentsController {
|
||||||
|
constructor(private readonly intentsService: IntentsService) {}
|
||||||
|
|
||||||
|
@Post("initiate")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Create (or idempotently reuse) a payment intent and open a provider session",
|
||||||
|
description:
|
||||||
|
"One active intent per (service, referenceType, referenceId). Re-initiating a non-terminal intent returns the existing clientAction.",
|
||||||
|
})
|
||||||
|
async initiate(
|
||||||
|
@Body() dto: InitiatePaymentRequestDto,
|
||||||
|
): Promise<PaymentIntentSnapshot> {
|
||||||
|
return this.intentsService.initiate(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("intents/:id")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Intent status by id (pull/reconcile)",
|
||||||
|
description:
|
||||||
|
"Stale non-terminal intents trigger a provider status query before returning.",
|
||||||
|
})
|
||||||
|
async getIntent(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
): Promise<PaymentIntentSnapshot> {
|
||||||
|
return this.intentsService.getIntent(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("intents")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Active intent status by domain reference (pull/reconcile)",
|
||||||
|
})
|
||||||
|
async getIntentByReference(
|
||||||
|
@Query() query: IntentReferenceQueryDto,
|
||||||
|
): Promise<PaymentIntentSnapshot> {
|
||||||
|
return this.intentsService.getIntentByReference(
|
||||||
|
query.service,
|
||||||
|
query.referenceType,
|
||||||
|
query.referenceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/edr-payment-api/src/modules/intents/intents.module.ts
Normal file
21
apps/edr-payment-api/src/modules/intents/intents.module.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { ProvidersModule } from "../providers/providers.module";
|
||||||
|
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
|
||||||
|
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||||
|
import { IntentsController } from "./intents.controller";
|
||||||
|
import { IntentsRepository } from "./intents.repository";
|
||||||
|
import { IntentsService } from "./intents.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
// NotificationOutbox is registered here because terminal transitions insert outbox rows
|
||||||
|
// inside the intent-finalizing transaction (transactional outbox).
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([PaymentIntent, NotificationOutbox]),
|
||||||
|
ProvidersModule,
|
||||||
|
],
|
||||||
|
controllers: [IntentsController],
|
||||||
|
providers: [IntentsService, IntentsRepository],
|
||||||
|
exports: [IntentsService, IntentsRepository],
|
||||||
|
})
|
||||||
|
export class IntentsModule {}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { In, LessThan, Not, Repository } from "typeorm";
|
||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import {
|
||||||
|
PaymentReferenceType,
|
||||||
|
PaymentService,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IntentsRepository extends BaseRepository<PaymentIntent> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(PaymentIntent)
|
||||||
|
repository: Repository<PaymentIntent>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single non-FAILED/CANCELLED intent for a domain order (matches the partial unique index). */
|
||||||
|
async findActiveByReference(
|
||||||
|
service: PaymentService,
|
||||||
|
referenceType: PaymentReferenceType,
|
||||||
|
referenceId: string,
|
||||||
|
): Promise<PaymentIntent | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
service,
|
||||||
|
referenceType,
|
||||||
|
referenceId,
|
||||||
|
status: Not(
|
||||||
|
In([ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByMerchantOrderId(
|
||||||
|
merchantOrderId: string,
|
||||||
|
): Promise<PaymentIntent | null> {
|
||||||
|
return this.repository.findOne({ where: { merchantOrderId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIdempotencyKey(
|
||||||
|
service: PaymentService,
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<PaymentIntent | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { service, idempotencyKey },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-terminal intents untouched since `updatedBefore` — input for the reconciliation sweep. */
|
||||||
|
async findStale(
|
||||||
|
updatedBefore: Date,
|
||||||
|
limit: number,
|
||||||
|
): Promise<PaymentIntent[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: {
|
||||||
|
status: In([
|
||||||
|
ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
|
ProviderPaymentStatus.PROCESSING,
|
||||||
|
]),
|
||||||
|
updatedAt: LessThan(updatedBefore),
|
||||||
|
},
|
||||||
|
order: { updatedAt: "ASC" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
343
apps/edr-payment-api/src/modules/intents/intents.service.ts
Normal file
343
apps/edr-payment-api/src/modules/intents/intents.service.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { DataSource, QueryFailedError } from "typeorm";
|
||||||
|
import { createMerchantOrderId } from "@edr/payment-providers";
|
||||||
|
import {
|
||||||
|
InitiatePaymentRequest,
|
||||||
|
PaymentIntentSnapshot,
|
||||||
|
PaymentReferenceType,
|
||||||
|
PaymentService,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
ProviderStatus,
|
||||||
|
} from "@edr/types";
|
||||||
|
import {
|
||||||
|
PAYMENT_PROVIDER_MAP,
|
||||||
|
PaymentProviderMap,
|
||||||
|
} from "../providers/providers.module";
|
||||||
|
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
|
||||||
|
import { buildOutboxRow } from "../outbox/payment-event.factory";
|
||||||
|
import {
|
||||||
|
PaymentIntent,
|
||||||
|
TERMINAL_INTENT_STATUSES,
|
||||||
|
} from "./entities/payment-intent.entity";
|
||||||
|
import { IntentsRepository } from "./intents.repository";
|
||||||
|
|
||||||
|
const PG_UNIQUE_VIOLATION = "23505";
|
||||||
|
/** Don't hit the provider again if the intent was refreshed this recently. */
|
||||||
|
const REFRESH_MIN_AGE_MS = 5_000;
|
||||||
|
|
||||||
|
/** Result of a provider signal (webhook or status query) applied to the state machine. */
|
||||||
|
export interface ProviderResultInput {
|
||||||
|
status: ProviderPaymentStatus;
|
||||||
|
providerTxnId?: string;
|
||||||
|
paidAt?: Date;
|
||||||
|
confirmedAmountMinor?: number;
|
||||||
|
failureCode?: string;
|
||||||
|
failureMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IntentsService {
|
||||||
|
private readonly logger = new Logger(IntentsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly intentsRepository: IntentsRepository,
|
||||||
|
// DataSource is used only for the finalize transaction (intent update + outbox insert
|
||||||
|
// must commit atomically); routine access still goes through the custom repository.
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
@Inject(PAYMENT_PROVIDER_MAP)
|
||||||
|
private readonly providers: PaymentProviderMap,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ initiate */
|
||||||
|
|
||||||
|
async initiate(
|
||||||
|
request: InitiatePaymentRequest,
|
||||||
|
): Promise<PaymentIntentSnapshot> {
|
||||||
|
|
||||||
|
if (request.idempotencyKey) {
|
||||||
|
const byKey = await this.intentsRepository.findByIdempotencyKey(
|
||||||
|
request.service,
|
||||||
|
request.idempotencyKey,
|
||||||
|
);
|
||||||
|
if (byKey) return this.toSnapshot(byKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.intentsRepository.findActiveByReference(
|
||||||
|
request.service,
|
||||||
|
request.referenceType,
|
||||||
|
request.referenceId,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
const reusable = await this.reuseOrRetire(existing);
|
||||||
|
if (reusable) return this.toSnapshot(reusable);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = this.providers.get(request.provider);
|
||||||
|
if (!provider) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Unsupported payment provider: ${request.provider}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merchantOrderId = createMerchantOrderId();
|
||||||
|
const result = await provider.initiate({
|
||||||
|
merchantOrderId,
|
||||||
|
orderRef: request.orderRef ?? request.referenceId,
|
||||||
|
amountMinor: request.amountMinor,
|
||||||
|
currency: request.currency,
|
||||||
|
platform: request.platform,
|
||||||
|
payerAccount: request.payerAccount,
|
||||||
|
returnUrl: request.returnUrl,
|
||||||
|
redirectUrl: request.returnUrl,
|
||||||
|
failureUrl: request.failureUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const intent = await this.intentsRepository.create({
|
||||||
|
service: request.service,
|
||||||
|
referenceType: request.referenceType,
|
||||||
|
referenceId: request.referenceId,
|
||||||
|
merchantOrderId,
|
||||||
|
provider: request.provider,
|
||||||
|
providerOrderId: result.providerOrderId,
|
||||||
|
amountMinor: request.amountMinor,
|
||||||
|
currency: request.currency,
|
||||||
|
status: ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
|
clientAction: result.clientAction,
|
||||||
|
idempotencyKey: request.idempotencyKey ?? null,
|
||||||
|
expiresAt: result.expiresAt,
|
||||||
|
rawInitiation: result.rawInitiation,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`,
|
||||||
|
);
|
||||||
|
return this.toSnapshot(intent);
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof QueryFailedError &&
|
||||||
|
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
|
||||||
|
) {
|
||||||
|
const winner = await this.intentsRepository.findActiveByReference(
|
||||||
|
request.service,
|
||||||
|
request.referenceType,
|
||||||
|
request.referenceId,
|
||||||
|
);
|
||||||
|
if (winner) return this.toSnapshot(winner);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether an existing active intent can be returned as-is. An expired
|
||||||
|
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
|
||||||
|
* so a fresh provider session can be opened.
|
||||||
|
*/
|
||||||
|
private async reuseOrRetire(
|
||||||
|
intent: PaymentIntent,
|
||||||
|
): Promise<PaymentIntent | null> {
|
||||||
|
const expired =
|
||||||
|
intent.status === ProviderPaymentStatus.REQUIRES_ACTION &&
|
||||||
|
intent.expiresAt != null &&
|
||||||
|
intent.expiresAt.getTime() < Date.now();
|
||||||
|
if (!expired) return intent;
|
||||||
|
|
||||||
|
await this.intentsRepository.update(intent.id, {
|
||||||
|
status: ProviderPaymentStatus.CANCELLED,
|
||||||
|
failureCode: "EXPIRED",
|
||||||
|
failureMessage: "Provider session expired before the payer acted",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ lookups */
|
||||||
|
|
||||||
|
async getIntent(id: string): Promise<PaymentIntentSnapshot> {
|
||||||
|
const intent = await this.intentsRepository.findById(id);
|
||||||
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||||
|
return this.toSnapshot(await this.refreshIfStale(intent));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIntentByReference(
|
||||||
|
service: PaymentService,
|
||||||
|
referenceType: PaymentReferenceType,
|
||||||
|
referenceId: string,
|
||||||
|
): Promise<PaymentIntentSnapshot> {
|
||||||
|
const intent = await this.intentsRepository.findActiveByReference(
|
||||||
|
service,
|
||||||
|
referenceType,
|
||||||
|
referenceId,
|
||||||
|
);
|
||||||
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||||
|
return this.toSnapshot(await this.refreshIfStale(intent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the
|
||||||
|
* provider for the truth and run the answer through the state machine. The browser
|
||||||
|
* redirect never confirms payment — this query (or a webhook) does.
|
||||||
|
*/
|
||||||
|
private async refreshIfStale(intent: PaymentIntent): Promise<PaymentIntent> {
|
||||||
|
const refreshable =
|
||||||
|
intent.status === ProviderPaymentStatus.REQUIRES_ACTION ||
|
||||||
|
intent.status === ProviderPaymentStatus.PROCESSING;
|
||||||
|
const stale = intent.updatedAt.getTime() < Date.now() - REFRESH_MIN_AGE_MS;
|
||||||
|
const provider = this.providers.get(intent.provider);
|
||||||
|
if (!refreshable || !stale || !provider) return intent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await provider.queryStatus(intent.merchantOrderId);
|
||||||
|
await this.applyProviderResult(
|
||||||
|
intent.id,
|
||||||
|
this.fromProviderStatus(status),
|
||||||
|
);
|
||||||
|
return (await this.intentsRepository.findById(intent.id)) ?? intent;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.warn(
|
||||||
|
`queryStatus failed for intent ${intent.id}: ${message}; returning cached`,
|
||||||
|
);
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fromProviderStatus(status: ProviderStatus): ProviderResultInput {
|
||||||
|
return {
|
||||||
|
status: status.status,
|
||||||
|
providerTxnId: status.providerTxnId,
|
||||||
|
failureCode: status.failureCode,
|
||||||
|
failureMessage: status.failureMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ state machine */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance the intent state machine with a verified provider signal. Terminal states are
|
||||||
|
* absorbing; a terminal transition writes the notification_outbox row IN THE SAME
|
||||||
|
* TRANSACTION as the intent update (transactional outbox — architecture.md §8).
|
||||||
|
*/
|
||||||
|
async applyProviderResult(
|
||||||
|
intentId: string,
|
||||||
|
result: ProviderResultInput,
|
||||||
|
): Promise<{ alreadyTerminal: boolean }> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const intent = await manager
|
||||||
|
.getRepository(PaymentIntent)
|
||||||
|
.createQueryBuilder("intent")
|
||||||
|
.setLock("pessimistic_write")
|
||||||
|
.where("intent.id = :intentId", { intentId })
|
||||||
|
.getOne();
|
||||||
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||||
|
|
||||||
|
if (
|
||||||
|
(TERMINAL_INTENT_STATUSES as readonly ProviderPaymentStatus[]).includes(
|
||||||
|
intent.status,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { alreadyTerminal: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
|
const paidAt = result.paidAt ?? new Date();
|
||||||
|
intent.status = ProviderPaymentStatus.SUCCEEDED;
|
||||||
|
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
|
||||||
|
intent.paidAt = paidAt;
|
||||||
|
intent.confirmedAmountMinor =
|
||||||
|
result.confirmedAmountMinor ?? intent.confirmedAmountMinor;
|
||||||
|
intent.failureCode = null;
|
||||||
|
intent.failureMessage = null;
|
||||||
|
await manager.save(intent);
|
||||||
|
await manager.getRepository(NotificationOutbox).save(
|
||||||
|
buildOutboxRow(intent, {
|
||||||
|
eventType: "payment.succeeded",
|
||||||
|
providerTxnId: intent.providerTxnId ?? undefined,
|
||||||
|
paidAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
result.confirmedAmountMinor != null &&
|
||||||
|
result.confirmedAmountMinor !== intent.amountMinor
|
||||||
|
) {
|
||||||
|
this.logger.error(
|
||||||
|
`intent ${intent.id} amount mismatch: asserted=${intent.amountMinor} confirmed=${result.confirmedAmountMinor}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`intent ${intent.id} SUCCEEDED (txn=${intent.providerTxnId ?? "n/a"})`,
|
||||||
|
);
|
||||||
|
return { alreadyTerminal: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
result.status === ProviderPaymentStatus.FAILED ||
|
||||||
|
result.status === ProviderPaymentStatus.CANCELLED
|
||||||
|
) {
|
||||||
|
intent.status = result.status;
|
||||||
|
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
|
||||||
|
intent.failureCode = result.failureCode ?? null;
|
||||||
|
intent.failureMessage = result.failureMessage ?? null;
|
||||||
|
await manager.save(intent);
|
||||||
|
await manager.getRepository(NotificationOutbox).save(
|
||||||
|
buildOutboxRow(intent, {
|
||||||
|
eventType: "payment.failed",
|
||||||
|
failureCode: result.failureCode,
|
||||||
|
failureMessage: result.failureMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`intent ${intent.id} ${result.status} (${result.failureCode ?? "n/a"})`,
|
||||||
|
);
|
||||||
|
return { alreadyTerminal: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-terminal: REQUIRES_ACTION may move to PROCESSING; never the reverse.
|
||||||
|
if (
|
||||||
|
result.status === ProviderPaymentStatus.PROCESSING &&
|
||||||
|
intent.status === ProviderPaymentStatus.REQUIRES_ACTION
|
||||||
|
) {
|
||||||
|
intent.status = ProviderPaymentStatus.PROCESSING;
|
||||||
|
}
|
||||||
|
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
|
||||||
|
await manager.save(intent);
|
||||||
|
return { alreadyTerminal: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expire an abandoned intent (reconciliation sweep) — CANCELLED + payment.failed event. */
|
||||||
|
async expireIntent(intentId: string): Promise<void> {
|
||||||
|
await this.applyProviderResult(intentId, {
|
||||||
|
status: ProviderPaymentStatus.CANCELLED,
|
||||||
|
failureCode: "EXPIRED",
|
||||||
|
failureMessage: "Payment session expired before completion",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ mapping */
|
||||||
|
|
||||||
|
toSnapshot(intent: PaymentIntent): PaymentIntentSnapshot {
|
||||||
|
return {
|
||||||
|
intentId: intent.id,
|
||||||
|
service: intent.service,
|
||||||
|
referenceType: intent.referenceType,
|
||||||
|
referenceId: intent.referenceId,
|
||||||
|
merchantOrderId: intent.merchantOrderId,
|
||||||
|
provider: intent.provider,
|
||||||
|
status: intent.status,
|
||||||
|
amountMinor: intent.amountMinor,
|
||||||
|
currency: intent.currency,
|
||||||
|
clientAction: intent.clientAction ?? undefined,
|
||||||
|
providerTxnId: intent.providerTxnId ?? undefined,
|
||||||
|
paidAt: intent.paidAt?.toISOString(),
|
||||||
|
failureCode: intent.failureCode ?? undefined,
|
||||||
|
failureMessage: intent.failureMessage ?? undefined,
|
||||||
|
expiresAt: intent.expiresAt?.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import {
|
||||||
|
PaymentEvent,
|
||||||
|
PaymentEventType,
|
||||||
|
PaymentReferenceType,
|
||||||
|
PaymentService,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
export type OutboxStatus = "PENDING" | "SENT" | "FAILED";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactional outbox: a row is inserted in the SAME transaction that finalizes an intent,
|
||||||
|
* so "payment succeeded" and "a notification is owed" commit or roll back together. The relay
|
||||||
|
* drains PENDING rows and retries until acked (at-least-once delivery; consumers are idempotent).
|
||||||
|
*/
|
||||||
|
@Entity({ name: "notification_outbox" })
|
||||||
|
@Index("idx_notification_outbox_relay", ["status", "nextRetryAt"])
|
||||||
|
export class NotificationOutbox extends BaseEntity {
|
||||||
|
@Column({ name: "event_type", type: "varchar", length: 32 })
|
||||||
|
eventType!: PaymentEventType;
|
||||||
|
|
||||||
|
/** Routing discriminator — which app's mark-paid endpoint the relay delivers to. */
|
||||||
|
@Column({ name: "service", type: "varchar", length: 16 })
|
||||||
|
service!: PaymentService;
|
||||||
|
|
||||||
|
@Index("idx_notification_outbox_intent")
|
||||||
|
@Column({ name: "intent_id", type: "uuid" })
|
||||||
|
intentId!: string;
|
||||||
|
|
||||||
|
@Column({ name: "reference_type", type: "varchar", length: 16 })
|
||||||
|
referenceType!: PaymentReferenceType;
|
||||||
|
|
||||||
|
@Column({ name: "reference_id", type: "varchar", length: 64 })
|
||||||
|
referenceId!: string;
|
||||||
|
|
||||||
|
/** The full versioned event envelope delivered verbatim to the consumer. */
|
||||||
|
@Column({ name: "payload", type: "jsonb" })
|
||||||
|
payload!: PaymentEvent;
|
||||||
|
|
||||||
|
@Column({ name: "status", type: "varchar", length: 16, default: "PENDING" })
|
||||||
|
status!: OutboxStatus;
|
||||||
|
|
||||||
|
@Column({ name: "attempts", type: "integer", default: 0 })
|
||||||
|
attempts!: number;
|
||||||
|
|
||||||
|
@Column({ name: "next_retry_at", type: "timestamptz", nullable: true })
|
||||||
|
nextRetryAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "last_error", type: "text", nullable: true })
|
||||||
|
lastError?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "sent_at", type: "timestamptz", nullable: true })
|
||||||
|
sentAt?: Date | null;
|
||||||
|
}
|
||||||
119
apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts
Normal file
119
apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||||
|
import { NotificationOutbox } from "./entities/notification-outbox.entity";
|
||||||
|
import { OutboxRepository } from "./outbox.repository";
|
||||||
|
import {
|
||||||
|
PAYMENT_EVENT_PUBLISHER,
|
||||||
|
PaymentEventPublisher,
|
||||||
|
} from "./publisher/payment-event-publisher";
|
||||||
|
|
||||||
|
const RELAY_INTERVAL_NAME = "outbox-relay";
|
||||||
|
/** Retry backoff: base doubles per attempt, capped. */
|
||||||
|
const BACKOFF_BASE_MS = 10_000;
|
||||||
|
const BACKOFF_CAP_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drains the transactional outbox: PENDING rows are published (HTTP now, RabbitMQ later),
|
||||||
|
* marked SENT on ack, retried with exponential backoff on failure, and flagged FAILED after
|
||||||
|
* OUTBOX_MAX_ATTEMPTS (an alertable condition — delivery is at-least-once, never dropped
|
||||||
|
* silently). A crash between commit and publish only delays delivery.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxRelayService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(OutboxRelayService.name);
|
||||||
|
private readonly intervalMs: number;
|
||||||
|
private readonly maxAttempts: number;
|
||||||
|
private readonly batchSize: number;
|
||||||
|
private draining = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly outboxRepository: OutboxRepository,
|
||||||
|
private readonly schedulerRegistry: SchedulerRegistry,
|
||||||
|
@Inject(PAYMENT_EVENT_PUBLISHER)
|
||||||
|
private readonly publisher: PaymentEventPublisher,
|
||||||
|
) {
|
||||||
|
this.intervalMs = config.get<number>("notifier.relayIntervalMs") ?? 5_000;
|
||||||
|
this.maxAttempts = config.get<number>("notifier.maxAttempts") ?? 10;
|
||||||
|
this.batchSize = config.get<number>("notifier.relayBatchSize") ?? 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
const interval = setInterval(() => void this.drain(), this.intervalMs);
|
||||||
|
this.schedulerRegistry.addInterval(RELAY_INTERVAL_NAME, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.schedulerRegistry.doesExist("interval", RELAY_INTERVAL_NAME)) {
|
||||||
|
this.schedulerRegistry.deleteInterval(RELAY_INTERVAL_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One relay pass; re-entrant ticks are skipped so slow deliveries don't overlap. */
|
||||||
|
async drain(): Promise<void> {
|
||||||
|
if (this.draining) return;
|
||||||
|
this.draining = true;
|
||||||
|
try {
|
||||||
|
const due = await this.outboxRepository.findDue(this.batchSize);
|
||||||
|
for (const row of due) {
|
||||||
|
await this.deliver(row);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`relay pass failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.draining = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliver(row: NotificationOutbox): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.publisher.publish(row.payload);
|
||||||
|
await this.outboxRepository.markSent(row.id);
|
||||||
|
} catch (err) {
|
||||||
|
const message = this.describeError(err);
|
||||||
|
const attempts = row.attempts + 1;
|
||||||
|
const exhausted = attempts >= this.maxAttempts;
|
||||||
|
const backoffMs = Math.min(
|
||||||
|
BACKOFF_BASE_MS * 2 ** row.attempts,
|
||||||
|
BACKOFF_CAP_MS,
|
||||||
|
);
|
||||||
|
await this.outboxRepository.markAttemptFailed(
|
||||||
|
row,
|
||||||
|
message,
|
||||||
|
exhausted ? null : new Date(Date.now() + backoffMs),
|
||||||
|
exhausted,
|
||||||
|
);
|
||||||
|
if (exhausted) {
|
||||||
|
// ALERT: a paid order may not be confirmed in the owning app — needs operator action.
|
||||||
|
this.logger.error(
|
||||||
|
`outbox ${row.id} (${row.eventType} intent=${row.intentId}) FAILED after ${attempts} attempts: ${message}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`outbox ${row.id} delivery attempt ${attempts} failed (retry in ${backoffMs}ms): ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connection failures surface as AggregateError with an empty message — dig out the code. */
|
||||||
|
private describeError(err: unknown): string {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.message) return err.message;
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
if (code) return code;
|
||||||
|
const inner = (err as { errors?: unknown[] }).errors?.[0];
|
||||||
|
if (inner instanceof Error && inner.message) return inner.message;
|
||||||
|
}
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/edr-payment-api/src/modules/outbox/outbox.module.ts
Normal file
20
apps/edr-payment-api/src/modules/outbox/outbox.module.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { NotificationOutbox } from "./entities/notification-outbox.entity";
|
||||||
|
import { OutboxRelayService } from "./outbox-relay.service";
|
||||||
|
import { OutboxRepository } from "./outbox.repository";
|
||||||
|
import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher";
|
||||||
|
import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([NotificationOutbox]), HttpModule],
|
||||||
|
providers: [
|
||||||
|
OutboxRepository,
|
||||||
|
OutboxRelayService,
|
||||||
|
// Swap to RabbitPaymentEventPublisher here when the broker lands — nothing else changes.
|
||||||
|
{ provide: PAYMENT_EVENT_PUBLISHER, useClass: HttpPaymentEventPublisher },
|
||||||
|
],
|
||||||
|
exports: [OutboxRepository],
|
||||||
|
})
|
||||||
|
export class OutboxModule {}
|
||||||
58
apps/edr-payment-api/src/modules/outbox/outbox.repository.ts
Normal file
58
apps/edr-payment-api/src/modules/outbox/outbox.repository.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { NotificationOutbox } from "./entities/notification-outbox.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxRepository extends BaseRepository<NotificationOutbox> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(NotificationOutbox)
|
||||||
|
repository: Repository<NotificationOutbox>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PENDING rows whose retry time has come, oldest first. The relay runs as a single
|
||||||
|
* non-overlapping loop per instance; with multiple service instances this should move to a
|
||||||
|
* SELECT … FOR UPDATE SKIP LOCKED claim.
|
||||||
|
*/
|
||||||
|
async findDue(limit: number): Promise<NotificationOutbox[]> {
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder("outbox")
|
||||||
|
.where(`outbox.status = 'PENDING'`)
|
||||||
|
.andWhere(
|
||||||
|
"(outbox.next_retry_at IS NULL OR outbox.next_retry_at <= now())",
|
||||||
|
)
|
||||||
|
.orderBy("outbox.created_at", "ASC")
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async markSent(id: string): Promise<void> {
|
||||||
|
await this.update(id, {
|
||||||
|
status: "SENT",
|
||||||
|
sentAt: new Date(),
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAttemptFailed(
|
||||||
|
row: NotificationOutbox,
|
||||||
|
error: string,
|
||||||
|
nextRetryAt: Date | null,
|
||||||
|
exhausted: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.update(row.id, {
|
||||||
|
attempts: row.attempts + 1,
|
||||||
|
lastError: error,
|
||||||
|
nextRetryAt,
|
||||||
|
status: exhausted ? "FAILED" : "PENDING",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async countBacklog(): Promise<number> {
|
||||||
|
return this.repository.count({ where: { status: "PENDING" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
PaymentEvent,
|
||||||
|
PaymentFailedEvent,
|
||||||
|
PaymentSucceededEvent,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||||
|
import { NotificationOutbox } from "./entities/notification-outbox.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a ready-to-insert outbox row for a terminal intent. Pure (no DI) so the intents
|
||||||
|
* state machine can insert it inside its own DB transaction without a module cycle.
|
||||||
|
* The row id is generated here because the event envelope embeds it as `eventId`.
|
||||||
|
*/
|
||||||
|
export function buildOutboxRow(
|
||||||
|
intent: PaymentIntent,
|
||||||
|
terminal:
|
||||||
|
| { eventType: "payment.succeeded"; providerTxnId?: string; paidAt: Date }
|
||||||
|
| {
|
||||||
|
eventType: "payment.failed";
|
||||||
|
failureCode?: string;
|
||||||
|
failureMessage?: string;
|
||||||
|
},
|
||||||
|
): Partial<NotificationOutbox> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const base = {
|
||||||
|
version: 1 as const,
|
||||||
|
eventId: id,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
service: intent.service,
|
||||||
|
intentId: intent.id,
|
||||||
|
referenceType: intent.referenceType,
|
||||||
|
referenceId: intent.referenceId,
|
||||||
|
merchantOrderId: intent.merchantOrderId,
|
||||||
|
provider: intent.provider,
|
||||||
|
amountMinor: intent.amountMinor,
|
||||||
|
currency: intent.currency,
|
||||||
|
};
|
||||||
|
|
||||||
|
const event: PaymentEvent =
|
||||||
|
terminal.eventType === "payment.succeeded"
|
||||||
|
? ({
|
||||||
|
...base,
|
||||||
|
eventType: "payment.succeeded",
|
||||||
|
providerTxnId: terminal.providerTxnId,
|
||||||
|
paidAt: terminal.paidAt.toISOString(),
|
||||||
|
} satisfies PaymentSucceededEvent)
|
||||||
|
: ({
|
||||||
|
...base,
|
||||||
|
eventType: "payment.failed",
|
||||||
|
failureCode: terminal.failureCode,
|
||||||
|
failureMessage: terminal.failureMessage,
|
||||||
|
} satisfies PaymentFailedEvent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
eventType: event.eventType,
|
||||||
|
service: intent.service,
|
||||||
|
intentId: intent.id,
|
||||||
|
referenceType: intent.referenceType,
|
||||||
|
referenceId: intent.referenceId,
|
||||||
|
payload: event,
|
||||||
|
status: "PENDING",
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { HttpService } from "@nestjs/axios";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
import { PaymentEvent, PaymentService } from "@edr/types";
|
||||||
|
import { PaymentEventPublisher } from "./payment-event-publisher";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivers events by POSTing to the owning app's idempotent mark-paid endpoint, routed by
|
||||||
|
* the `service` discriminator. Authenticated with the shared service token (the same secret
|
||||||
|
* the apps use to call /payments/initiate).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HttpPaymentEventPublisher implements PaymentEventPublisher {
|
||||||
|
private readonly logger = new Logger(HttpPaymentEventPublisher.name);
|
||||||
|
private readonly routes: Record<PaymentService, string>;
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
private readonly serviceToken: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly http: HttpService,
|
||||||
|
) {
|
||||||
|
this.routes = {
|
||||||
|
[PaymentService.PASSENGER]:
|
||||||
|
config.get<string>("notifier.passengerUrl") ?? "",
|
||||||
|
[PaymentService.FREIGHT]: config.get<string>("notifier.freightUrl") ?? "",
|
||||||
|
};
|
||||||
|
this.timeoutMs = config.get<number>("notifier.httpTimeoutMs") ?? 10_000;
|
||||||
|
this.serviceToken = config.get<string>("app.serviceAuthToken") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(event: PaymentEvent): Promise<void> {
|
||||||
|
const url = this.routes[event.service];
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
`No mark-paid URL configured for service ${event.service}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post(url, event, {
|
||||||
|
timeout: this.timeoutMs,
|
||||||
|
headers: this.serviceToken
|
||||||
|
? { "x-service-token": this.serviceToken }
|
||||||
|
: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`delivered ${event.eventType} (${event.eventId}) to ${event.service} — HTTP ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PaymentEvent } from "@edr/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publisher port (architecture.md §12): how a payment event leaves this service.
|
||||||
|
* HTTP implementation now; a RabbitMQ implementation later is a DI swap only — the outbox
|
||||||
|
* and relay stay exactly as they are.
|
||||||
|
*/
|
||||||
|
export interface PaymentEventPublisher {
|
||||||
|
/** Deliver one event; throw on failure so the relay can retry with backoff. */
|
||||||
|
publish(event: PaymentEvent): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PAYMENT_EVENT_PUBLISHER = Symbol("PAYMENT_EVENT_PUBLISHER");
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
|
import {
|
||||||
|
CardProvider,
|
||||||
|
CbeBirrProvider,
|
||||||
|
DMoneyProvider,
|
||||||
|
EBirrProvider,
|
||||||
|
PaymentProvider,
|
||||||
|
TelebirrProvider,
|
||||||
|
WaafiProvider,
|
||||||
|
} from "@edr/payment-providers";
|
||||||
|
import { ProviderMethod } from "@edr/types";
|
||||||
|
|
||||||
|
/** Injection token for the Map<ProviderMethod, PaymentProvider> used to select a gateway. */
|
||||||
|
export const PAYMENT_PROVIDER_MAP = Symbol("PAYMENT_PROVIDER_MAP");
|
||||||
|
|
||||||
|
export type PaymentProviderMap = Map<ProviderMethod, PaymentProvider>;
|
||||||
|
|
||||||
|
const providerClasses = [
|
||||||
|
TelebirrProvider,
|
||||||
|
CbeBirrProvider,
|
||||||
|
EBirrProvider,
|
||||||
|
CardProvider,
|
||||||
|
WaafiProvider,
|
||||||
|
DMoneyProvider,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin DI wiring around @edr/payment-providers — the exact provider set the passenger app
|
||||||
|
* used to construct, relocated here. After cutover this service is the only consumer of the
|
||||||
|
* provider SDK and of the provider secrets (config/{waafi,telebirr,…}.config.ts).
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [HttpModule.register({ timeout: 10_000 })],
|
||||||
|
providers: [
|
||||||
|
...providerClasses,
|
||||||
|
{
|
||||||
|
provide: PAYMENT_PROVIDER_MAP,
|
||||||
|
useFactory: (...providers: PaymentProvider[]): PaymentProviderMap =>
|
||||||
|
new Map(providers.map((provider) => [provider.method, provider])),
|
||||||
|
inject: providerClasses,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [PAYMENT_PROVIDER_MAP, ...providerClasses],
|
||||||
|
})
|
||||||
|
export class ProvidersModule {}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { IntentsModule } from "../intents/intents.module";
|
||||||
|
import { ProvidersModule } from "../providers/providers.module";
|
||||||
|
import { ReconciliationService } from "./reconciliation.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [IntentsModule, ProvidersModule],
|
||||||
|
providers: [ReconciliationService],
|
||||||
|
})
|
||||||
|
export class ReconciliationModule {}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||||
|
import { ProviderPaymentStatus } from "@edr/types";
|
||||||
|
import {
|
||||||
|
PAYMENT_PROVIDER_MAP,
|
||||||
|
PaymentProviderMap,
|
||||||
|
} from "../providers/providers.module";
|
||||||
|
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||||
|
import { IntentsRepository } from "../intents/intents.repository";
|
||||||
|
import { IntentsService } from "../intents/intents.service";
|
||||||
|
|
||||||
|
const SWEEP_INTERVAL_NAME = "reconciliation-sweep";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safety net (architecture.md §7.4): webhooks get lost, users abandon hosted pages. The sweep
|
||||||
|
* queries the provider for stale non-terminal intents and feeds the answer through the same
|
||||||
|
* state machine the webhooks use; intents whose provider session expired are CANCELLED.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(ReconciliationService.name);
|
||||||
|
private readonly intervalMs: number;
|
||||||
|
private readonly staleAfterMs: number;
|
||||||
|
private readonly batchSize: number;
|
||||||
|
private sweeping = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly intentsRepository: IntentsRepository,
|
||||||
|
private readonly intentsService: IntentsService,
|
||||||
|
private readonly schedulerRegistry: SchedulerRegistry,
|
||||||
|
@Inject(PAYMENT_PROVIDER_MAP)
|
||||||
|
private readonly providers: PaymentProviderMap,
|
||||||
|
) {
|
||||||
|
this.intervalMs =
|
||||||
|
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 60_000;
|
||||||
|
this.staleAfterMs =
|
||||||
|
config.get<number>("app.reconciliation.staleAfterMs") ?? 60_000;
|
||||||
|
this.batchSize = config.get<number>("app.reconciliation.batchSize") ?? 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
const interval = setInterval(() => void this.sweep(), this.intervalMs);
|
||||||
|
this.schedulerRegistry.addInterval(SWEEP_INTERVAL_NAME, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.schedulerRegistry.doesExist("interval", SWEEP_INTERVAL_NAME)) {
|
||||||
|
this.schedulerRegistry.deleteInterval(SWEEP_INTERVAL_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sweep(): Promise<void> {
|
||||||
|
if (this.sweeping) return;
|
||||||
|
this.sweeping = true;
|
||||||
|
try {
|
||||||
|
const cutoff = new Date(Date.now() - this.staleAfterMs);
|
||||||
|
const stale = await this.intentsRepository.findStale(
|
||||||
|
cutoff,
|
||||||
|
this.batchSize,
|
||||||
|
);
|
||||||
|
for (const intent of stale) {
|
||||||
|
await this.reconcileIntent(intent);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`sweep failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.sweeping = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reconcileIntent(intent: PaymentIntent): Promise<void> {
|
||||||
|
try {
|
||||||
|
const provider = this.providers.get(intent.provider);
|
||||||
|
if (provider) {
|
||||||
|
const status = await provider.queryStatus(intent.merchantOrderId);
|
||||||
|
const result = this.intentsService.fromProviderStatus(status);
|
||||||
|
if (result.status !== intent.status || result.providerTxnId) {
|
||||||
|
await this.intentsService.applyProviderResult(intent.id, result);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.status === ProviderPaymentStatus.SUCCEEDED ||
|
||||||
|
result.status === ProviderPaymentStatus.FAILED ||
|
||||||
|
result.status === ProviderPaymentStatus.CANCELLED
|
||||||
|
) {
|
||||||
|
this.logger.log(`reconciled intent ${intent.id} → ${result.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider still says pending (or is unknown): expire only once the session is dead.
|
||||||
|
if (intent.expiresAt && intent.expiresAt.getTime() < Date.now()) {
|
||||||
|
await this.intentsService.expireIntent(intent.id);
|
||||||
|
this.logger.log(
|
||||||
|
`expired abandoned intent ${intent.id} (${intent.merchantOrderId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Per-intent failures must not stall the sweep; the row stays stale and is retried.
|
||||||
|
this.logger.warn(
|
||||||
|
`reconcile failed for intent ${intent.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { ProviderMethod } from "@edr/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotency + audit record for every inbound provider webhook. The unique
|
||||||
|
* (provider, external_event_id) pair is the dedupe key: a duplicate insert hits the unique
|
||||||
|
* violation and the handler short-circuits with a 200 ack.
|
||||||
|
*/
|
||||||
|
@Entity({ name: "payment_webhook_event" })
|
||||||
|
@Index("uq_payment_webhook_event_external", ["provider", "externalEventId"], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
export class PaymentWebhookEvent extends BaseEntity {
|
||||||
|
@Column({ name: "provider", type: "varchar", length: 16 })
|
||||||
|
provider!: ProviderMethod;
|
||||||
|
|
||||||
|
/** Provider event id when given (e.g. Waafi X-Webhook-Event-Id), else derived from the payload. */
|
||||||
|
@Column({ name: "external_event_id", type: "varchar", length: 191 })
|
||||||
|
externalEventId!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "merchant_order_id",
|
||||||
|
type: "varchar",
|
||||||
|
length: 64,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
merchantOrderId?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "provider_txn_id",
|
||||||
|
type: "varchar",
|
||||||
|
length: 128,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
providerTxnId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "signature_valid", type: "boolean", default: false })
|
||||||
|
signatureValid!: boolean;
|
||||||
|
|
||||||
|
/** Raw provider status string as sent (pre-mapping). */
|
||||||
|
@Column({ name: "status", type: "varchar", length: 64, nullable: true })
|
||||||
|
status?: string | null;
|
||||||
|
|
||||||
|
/** Full webhook body — hostile input, stored verbatim for audit/replay analysis. */
|
||||||
|
@Column({ name: "payload", type: "jsonb" })
|
||||||
|
payload!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ name: "received_at", type: "timestamptz", default: () => "now()" })
|
||||||
|
receivedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: "processed_at", type: "timestamptz", nullable: true })
|
||||||
|
processedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "processing_error", type: "text", nullable: true })
|
||||||
|
processingError?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { CardProvider, CardWebhookPayload } from "@edr/payment-providers";
|
||||||
|
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CardWebhookService {
|
||||||
|
constructor(
|
||||||
|
private readonly provider: CardProvider,
|
||||||
|
private readonly processor: WebhookProcessorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
|
||||||
|
const signatureValid = this.provider.verifyWebhookSignature(
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
signature,
|
||||||
|
);
|
||||||
|
const object = payload.data.object;
|
||||||
|
const mapped = this.provider.mapWebhookStatus(object.status);
|
||||||
|
|
||||||
|
await this.processor.process({
|
||||||
|
provider: this.provider.method,
|
||||||
|
externalEventId: `${payload.id}_${payload.type}`,
|
||||||
|
merchantOrderId: object.metadata.merchantOrderId,
|
||||||
|
providerTxnId: object.transaction_id,
|
||||||
|
signatureValid,
|
||||||
|
rawStatus: object.status,
|
||||||
|
payload: payload as unknown as Record<string, unknown>,
|
||||||
|
result: {
|
||||||
|
status: mapped,
|
||||||
|
providerTxnId: object.transaction_id,
|
||||||
|
failureCode: object.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { CbeBirrProvider, CbeBirrWebhookPayload } from "@edr/payment-providers";
|
||||||
|
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CbeBirrWebhookService {
|
||||||
|
constructor(
|
||||||
|
private readonly provider: CbeBirrProvider,
|
||||||
|
private readonly processor: WebhookProcessorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
|
||||||
|
const signatureValid = this.provider.verifyWebhookSignature(
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||||
|
const providerTxnId = payload.transactionId ?? payload.orderId;
|
||||||
|
|
||||||
|
await this.processor.process({
|
||||||
|
provider: this.provider.method,
|
||||||
|
externalEventId: `${payload.orderId}_${payload.status}`,
|
||||||
|
merchantOrderId: payload.merchantOrderId,
|
||||||
|
providerTxnId,
|
||||||
|
signatureValid,
|
||||||
|
rawStatus: payload.status,
|
||||||
|
payload: payload as unknown as Record<string, unknown>,
|
||||||
|
result: { status: mapped, providerTxnId, failureCode: payload.status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { DMoneyProvider, DMoneyWebhookPayload } from "@edr/payment-providers";
|
||||||
|
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DMoneyWebhookService {
|
||||||
|
constructor(
|
||||||
|
private readonly provider: DMoneyProvider,
|
||||||
|
private readonly processor: WebhookProcessorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(payload: DMoneyWebhookPayload): Promise<void> {
|
||||||
|
const signatureValid = this.provider.verifyWebhookSignature(
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
const mapped = this.provider.mapWebhookStatus(payload.status);
|
||||||
|
|
||||||
|
await this.processor.process({
|
||||||
|
provider: this.provider.method,
|
||||||
|
externalEventId: `${payload.orderId}_${payload.status}`,
|
||||||
|
merchantOrderId: payload.merchantOrderId,
|
||||||
|
providerTxnId: payload.transactionId,
|
||||||
|
signatureValid,
|
||||||
|
rawStatus: payload.status,
|
||||||
|
payload: payload as unknown as Record<string, unknown>,
|
||||||
|
result: {
|
||||||
|
status: mapped,
|
||||||
|
providerTxnId: payload.transactionId,
|
||||||
|
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
|
||||||
|
failureCode: payload.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers";
|
||||||
|
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EBirrWebhookService {
|
||||||
|
constructor(
|
||||||
|
private readonly provider: EBirrProvider,
|
||||||
|
private readonly processor: WebhookProcessorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(payload: EBirrWebhookPayload): Promise<void> {
|
||||||
|
const signatureValid = this.provider.verifyWebhookSignature(
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
|
||||||
|
|
||||||
|
await this.processor.process({
|
||||||
|
provider: this.provider.method,
|
||||||
|
externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`,
|
||||||
|
merchantOrderId: payload.orderNo,
|
||||||
|
providerTxnId: payload.tradeNo,
|
||||||
|
signatureValid,
|
||||||
|
rawStatus: payload.tradeStatus,
|
||||||
|
payload: payload as unknown as Record<string, unknown>,
|
||||||
|
result: {
|
||||||
|
status: mapped,
|
||||||
|
providerTxnId: payload.tradeNo,
|
||||||
|
failureCode: payload.tradeStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
TelebirrProvider,
|
||||||
|
TelebirrWebhookPayload,
|
||||||
|
} from "@edr/payment-providers";
|
||||||
|
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TelebirrWebhookService {
|
||||||
|
constructor(
|
||||||
|
private readonly provider: TelebirrProvider,
|
||||||
|
private readonly processor: WebhookProcessorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(payload: TelebirrWebhookPayload): Promise<void> {
|
||||||
|
// TODO: re-enable Telebirr public-key signature verification — skipped for now
|
||||||
|
// (carried over from the passenger handler; see telebirr.provider verifyWebhookSignature).
|
||||||
|
const signatureValid = true;
|
||||||
|
|
||||||
|
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||||
|
const providerTxnId = payload.trans_id ?? payload.payment_order_id;
|
||||||
|
|
||||||
|
await this.processor.process({
|
||||||
|
provider: this.provider.method,
|
||||||
|
externalEventId: `${payload.payment_order_id}_${payload.trade_status}`,
|
||||||
|
merchantOrderId: payload.merch_order_id,
|
||||||
|
providerTxnId,
|
||||||
|
signatureValid,
|
||||||
|
rawStatus: payload.trade_status,
|
||||||
|
payload: payload as unknown as Record<string, unknown>,
|
||||||
|
result: {
|
||||||
|
status: mapped,
|
||||||
|
providerTxnId,
|
||||||
|
paidAt: this.parseEpochSeconds(payload.trans_end_time),
|
||||||
|
failureCode: payload.trade_status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseEpochSeconds(raw: string | undefined): Date | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(n)) return undefined;
|
||||||
|
return new Date(n * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
WaafiProvider,
|
||||||
|
WaafiWebhookHeaders,
|
||||||
|
WaafiWebhookPayload,
|
||||||
|
} from "@edr/payment-providers";
|
||||||
|
import { WebhookProcessorService } from "../webhook-processor.service";
|
||||||
|
|
||||||
|
/** Reject webhooks whose timestamp is older than this (replay protection). */
|
||||||
|
const WAAFI_REPLAY_WINDOW_SECONDS = 300;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WaafiWebhookService {
|
||||||
|
private readonly logger = new Logger(WaafiWebhookService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly provider: WaafiProvider,
|
||||||
|
private readonly processor: WebhookProcessorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(
|
||||||
|
payload: WaafiWebhookPayload,
|
||||||
|
rawBody: string,
|
||||||
|
headers: WaafiWebhookHeaders,
|
||||||
|
): Promise<void> {
|
||||||
|
// Unsigned validation ping sent on registration — acknowledge without verifying or persisting.
|
||||||
|
if (payload.event === "webhook.test") {
|
||||||
|
this.logger.log("Waafi webhook.test ping received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { payment } = payload;
|
||||||
|
const eventId = headers["x-webhook-event-id"];
|
||||||
|
const timestamp = headers["x-webhook-timestamp"];
|
||||||
|
const signature = headers["x-webhook-signature"];
|
||||||
|
|
||||||
|
const signatureValid =
|
||||||
|
this.isFresh(timestamp) &&
|
||||||
|
this.provider.verifyWebhookSignature(
|
||||||
|
rawBody,
|
||||||
|
signature,
|
||||||
|
timestamp,
|
||||||
|
eventId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapped = this.provider.mapWebhookStatus(payment.status);
|
||||||
|
|
||||||
|
await this.processor.process({
|
||||||
|
provider: this.provider.method,
|
||||||
|
// X-Webhook-Event-Id is unique per event; fall back to a derived id if absent.
|
||||||
|
externalEventId: eventId ?? `${payment.transaction_id}_${payment.status}`,
|
||||||
|
merchantOrderId: payment.reference_id,
|
||||||
|
providerTxnId: payment.transaction_id,
|
||||||
|
signatureValid,
|
||||||
|
rawStatus: payment.status,
|
||||||
|
payload: payload as unknown as Record<string, unknown>,
|
||||||
|
result: {
|
||||||
|
status: mapped,
|
||||||
|
providerTxnId: payment.transaction_id,
|
||||||
|
paidAt: this.parseDate(payment.date),
|
||||||
|
failureCode: payment.status,
|
||||||
|
failureMessage: payment.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the webhook timestamp (unix seconds) is within the replay window. */
|
||||||
|
private isFresh(timestamp: string | undefined): boolean {
|
||||||
|
if (!timestamp) return false;
|
||||||
|
const ts = parseInt(timestamp, 10);
|
||||||
|
if (Number.isNaN(ts)) return false;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return Math.abs(now - ts) <= WAAFI_REPLAY_WINDOW_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse Waafi's "YYYY-MM-DD HH:mm:ss" payment date; undefined when unparseable. */
|
||||||
|
private parseDate(raw: string | undefined): Date | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const d = new Date(raw);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { QueryFailedError, Repository } from "typeorm";
|
||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
|
||||||
|
|
||||||
|
const PG_UNIQUE_VIOLATION = "23505";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebhookEventsRepository extends BaseRepository<PaymentWebhookEvent> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(PaymentWebhookEvent)
|
||||||
|
repository: Repository<PaymentWebhookEvent>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert the event, relying on the unique (provider, external_event_id) index for dedupe.
|
||||||
|
* Returns null when the event was already recorded (duplicate delivery / provider replay).
|
||||||
|
*/
|
||||||
|
async createDeduped(
|
||||||
|
data: Partial<PaymentWebhookEvent>,
|
||||||
|
): Promise<PaymentWebhookEvent | null> {
|
||||||
|
try {
|
||||||
|
return await this.create(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof QueryFailedError &&
|
||||||
|
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markProcessed(id: string, processingError?: string): Promise<void> {
|
||||||
|
await this.update(id, {
|
||||||
|
processedAt: new Date(),
|
||||||
|
processingError: processingError ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ProviderMethod } from "@edr/types";
|
||||||
|
import { IntentsRepository } from "../intents/intents.repository";
|
||||||
|
import {
|
||||||
|
IntentsService,
|
||||||
|
ProviderResultInput,
|
||||||
|
} from "../intents/intents.service";
|
||||||
|
import { WebhookEventsRepository } from "./webhook-events.repository";
|
||||||
|
|
||||||
|
/** A provider webhook reduced to the fields the shared pipeline needs. */
|
||||||
|
export interface NormalizedWebhook {
|
||||||
|
provider: ProviderMethod;
|
||||||
|
/** Provider event id (or a deterministic derivation) — the dedupe key. */
|
||||||
|
externalEventId: string;
|
||||||
|
merchantOrderId: string;
|
||||||
|
providerTxnId?: string;
|
||||||
|
signatureValid: boolean;
|
||||||
|
/** Raw provider status string, stored for audit. */
|
||||||
|
rawStatus: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
/** Mapped outcome to feed the intent state machine. */
|
||||||
|
result: ProviderResultInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared webhook pipeline every provider handler funnels into:
|
||||||
|
* persist+dedupe → signature gate → intent lookup → prefix/service cross-check →
|
||||||
|
* state machine → mark processed. Always returns (never throws) so controllers can
|
||||||
|
* ack 200 fast — providers like Waafi time out at 5s and do not retry.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WebhookProcessorService {
|
||||||
|
private readonly logger = new Logger(WebhookProcessorService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly webhookEvents: WebhookEventsRepository,
|
||||||
|
private readonly intentsRepository: IntentsRepository,
|
||||||
|
private readonly intentsService: IntentsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async process(webhook: NormalizedWebhook): Promise<void> {
|
||||||
|
const { provider, merchantOrderId } = webhook;
|
||||||
|
|
||||||
|
const eventRow = await this.webhookEvents.createDeduped({
|
||||||
|
provider,
|
||||||
|
externalEventId: webhook.externalEventId,
|
||||||
|
merchantOrderId,
|
||||||
|
providerTxnId: webhook.providerTxnId ?? null,
|
||||||
|
signatureValid: webhook.signatureValid,
|
||||||
|
status: webhook.rawStatus,
|
||||||
|
payload: webhook.payload,
|
||||||
|
});
|
||||||
|
if (!eventRow) {
|
||||||
|
this.logger.log(
|
||||||
|
`${provider} webhook duplicate: ${webhook.externalEventId} — short-circuit OK`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhook.signatureValid) {
|
||||||
|
this.logger.warn(
|
||||||
|
`${provider} webhook signature invalid/stale for ref=${merchantOrderId}`,
|
||||||
|
);
|
||||||
|
await this.webhookEvents.markProcessed(eventRow.id, "signature-invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent =
|
||||||
|
await this.intentsRepository.findByMerchantOrderId(merchantOrderId);
|
||||||
|
if (!intent) {
|
||||||
|
// Tolerated: webhook may have raced the intent commit, or the reference is foreign.
|
||||||
|
// The provider gets a 200; retry/poll/reconciliation converges later.
|
||||||
|
this.logger.warn(
|
||||||
|
`${provider} webhook: no PaymentIntent for ref=${merchantOrderId}`,
|
||||||
|
);
|
||||||
|
await this.webhookEvents.markProcessed(eventRow.id, "intent-not-found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.intentsService.applyProviderResult(intent.id, webhook.result);
|
||||||
|
await this.webhookEvents.markProcessed(eventRow.id);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(
|
||||||
|
`${provider} webhook processing failed for ${merchantOrderId}: ${message}`,
|
||||||
|
);
|
||||||
|
await this.webhookEvents.markProcessed(
|
||||||
|
eventRow.id,
|
||||||
|
`processing-error: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts
Normal file
145
apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
All,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
CardWebhookPayload,
|
||||||
|
CbeBirrWebhookPayload,
|
||||||
|
DMoneyWebhookPayload,
|
||||||
|
EBirrWebhookPayload,
|
||||||
|
TelebirrWebhookPayload,
|
||||||
|
WaafiWebhookHeaders,
|
||||||
|
WaafiWebhookPayload,
|
||||||
|
} from "@edr/payment-providers";
|
||||||
|
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
|
||||||
|
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
|
||||||
|
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
|
||||||
|
import { CardWebhookService } from "./handlers/card-webhook.service";
|
||||||
|
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
|
||||||
|
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONLY public surface of the payment service — the single registered webhook URL per
|
||||||
|
* provider for the whole platform. No service auth here (provider-facing); trust comes from
|
||||||
|
* signature verification inside each handler. Every route acks 2xx fast and never rethrows:
|
||||||
|
* Waafi times out at 5s and does NOT retry.
|
||||||
|
*/
|
||||||
|
@ApiTags("Provider Webhooks")
|
||||||
|
@Controller("webhooks")
|
||||||
|
export class WebhooksController {
|
||||||
|
private readonly logger = new Logger(WebhooksController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly telebirr: TelebirrWebhookService,
|
||||||
|
private readonly cbeBirr: CbeBirrWebhookService,
|
||||||
|
private readonly eBirr: EBirrWebhookService,
|
||||||
|
private readonly card: CardWebhookService,
|
||||||
|
private readonly waafi: WaafiWebhookService,
|
||||||
|
private readonly dMoney: DMoneyWebhookService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@All("telebirr")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Telebirr payment notification callback (Ethiopia)",
|
||||||
|
})
|
||||||
|
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
|
||||||
|
this.logger.log("Telebirr webhook called");
|
||||||
|
try {
|
||||||
|
await this.telebirr.handle(payload);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Telebirr webhook handler threw: ${this.message(err)}`);
|
||||||
|
}
|
||||||
|
return { code: "0", message: "OK" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("cbe-birr")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "CBE Birr payment notification callback (Ethiopia)",
|
||||||
|
})
|
||||||
|
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
|
||||||
|
try {
|
||||||
|
await this.cbeBirr.handle(payload);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`CBE Birr webhook handler threw: ${this.message(err)}`);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("ebirr")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" })
|
||||||
|
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
|
||||||
|
try {
|
||||||
|
await this.eBirr.handle(payload);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`);
|
||||||
|
}
|
||||||
|
return { code: "0000", message: "success" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("card")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Card payment notification callback (International)",
|
||||||
|
})
|
||||||
|
async receiveCard(
|
||||||
|
@Body() payload: CardWebhookPayload,
|
||||||
|
@Headers("stripe-signature") signature: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.card.handle(payload, signature);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Card webhook handler threw: ${this.message(err)}`);
|
||||||
|
}
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("waafi")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Waafi payment notification callback (Djibouti)" })
|
||||||
|
async receiveWaafi(
|
||||||
|
@Body() payload: WaafiWebhookPayload,
|
||||||
|
@Headers() headers: WaafiWebhookHeaders,
|
||||||
|
@Req() req: { rawBody?: Buffer },
|
||||||
|
) {
|
||||||
|
|
||||||
|
this.logger.log("\n\n\n\nWaafi payment notification callback (Djibouti)\n\n\n\n");
|
||||||
|
this.logger.log(
|
||||||
|
`Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON.
|
||||||
|
const rawBody = req.rawBody?.toString("utf8") ?? "";
|
||||||
|
await this.waafi.handle(payload, rawBody, headers);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Waafi webhook handler threw: ${this.message(err)}`);
|
||||||
|
}
|
||||||
|
return { responseCode: "2001", responseMsg: "Success" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("dmoney")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "D-Money payment notification callback (Djibouti)" })
|
||||||
|
async receiveDMoney(@Body() payload: DMoneyWebhookPayload) {
|
||||||
|
try {
|
||||||
|
await this.dMoney.handle(payload);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`D-Money webhook handler threw: ${this.message(err)}`);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private message(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts
Normal file
34
apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { IntentsModule } from "../intents/intents.module";
|
||||||
|
import { ProvidersModule } from "../providers/providers.module";
|
||||||
|
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
|
||||||
|
import { WebhookEventsRepository } from "./webhook-events.repository";
|
||||||
|
import { WebhookProcessorService } from "./webhook-processor.service";
|
||||||
|
import { WebhooksController } from "./webhooks.controller";
|
||||||
|
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
|
||||||
|
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
|
||||||
|
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
|
||||||
|
import { CardWebhookService } from "./handlers/card-webhook.service";
|
||||||
|
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
|
||||||
|
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([PaymentWebhookEvent]),
|
||||||
|
IntentsModule,
|
||||||
|
ProvidersModule,
|
||||||
|
],
|
||||||
|
controllers: [WebhooksController],
|
||||||
|
providers: [
|
||||||
|
WebhookEventsRepository,
|
||||||
|
WebhookProcessorService,
|
||||||
|
TelebirrWebhookService,
|
||||||
|
CbeBirrWebhookService,
|
||||||
|
EBirrWebhookService,
|
||||||
|
CardWebhookService,
|
||||||
|
WaafiWebhookService,
|
||||||
|
DMoneyWebhookService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class WebhooksModule {}
|
||||||
18
apps/edr-payment-api/src/scripts/migrate-revert.ts
Normal file
18
apps/edr-payment-api/src/scripts/migrate-revert.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { AppDataSource } from "../data-source";
|
||||||
|
|
||||||
|
/** `pnpm --filter @edr/payment-api migration:revert` — undo the most recent migration. */
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
try {
|
||||||
|
await AppDataSource.undoLastMigration();
|
||||||
|
console.log("reverted last migration");
|
||||||
|
} finally {
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
23
apps/edr-payment-api/src/scripts/migrate.ts
Normal file
23
apps/edr-payment-api/src/scripts/migrate.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { AppDataSource } from "../data-source";
|
||||||
|
import { ensurePaymentSchema } from "../config/ensure-schema";
|
||||||
|
|
||||||
|
/** `pnpm --filter @edr/payment-api migration:run` — ensure schema, then run pending migrations. */
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await ensurePaymentSchema();
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
try {
|
||||||
|
const applied = await AppDataSource.runMigrations();
|
||||||
|
for (const migration of applied) {
|
||||||
|
console.log(`applied: ${migration.name}`);
|
||||||
|
}
|
||||||
|
if (applied.length === 0) console.log("no pending migrations");
|
||||||
|
} finally {
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
4
apps/edr-payment-api/tsconfig.build.json
Normal file
4
apps/edr-payment-api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
14
apps/edr-payment-api/tsconfig.json
Normal file
14
apps/edr-payment-api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@edr/tsconfig/nestjs.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"noEmit": false,
|
||||||
|
"incremental": true,
|
||||||
|
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||||
|
"module": "node16",
|
||||||
|
"moduleResolution": "node16"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -83,6 +83,20 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- apps/edr-passenger-web/backoffice/.env
|
- apps/edr-passenger-web/backoffice/.env
|
||||||
|
|
||||||
|
payment-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/edr-payment-api/Dockerfile
|
||||||
|
args:
|
||||||
|
APP_PACKAGE: "@edr/payment-api"
|
||||||
|
APP_PATH: apps/edr-payment-api
|
||||||
|
secrets:
|
||||||
|
- npmrc
|
||||||
|
ports:
|
||||||
|
- "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}"
|
||||||
|
env_file:
|
||||||
|
- apps/edr-payment-api/.env
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
npmrc:
|
npmrc:
|
||||||
file: .npmrc
|
file: .npmrc
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"dev:freight": "turbo run dev --filter=@edr/freight-api... --filter=@edr/freight-portal... --filter=@edr/freight-backoffice... --filter=@edr/ui-common...",
|
"dev:freight": "turbo run dev --filter=@edr/freight-api... --filter=@edr/freight-portal... --filter=@edr/freight-backoffice... --filter=@edr/ui-common...",
|
||||||
"dev:passenger": "turbo run dev --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...",
|
"dev:passenger": "turbo run dev --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...",
|
||||||
|
"dev:payment": "turbo run dev --filter=@edr/payment-api...",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:freight": "turbo run build --filter=@edr/freight-api... --filter=@edr/freight-portal... --filter=@edr/freight-backoffice...",
|
"build:freight": "turbo run build --filter=@edr/freight-api... --filter=@edr/freight-portal... --filter=@edr/freight-backoffice...",
|
||||||
"build:passenger": "turbo run build --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...",
|
"build:passenger": "turbo run build --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...",
|
||||||
|
|||||||
@@ -40,12 +40,28 @@ export type {
|
|||||||
TelebirrTradeStatus,
|
TelebirrTradeStatus,
|
||||||
} from './providers/telebirr/telebirr.types';
|
} from './providers/telebirr/telebirr.types';
|
||||||
|
|
||||||
|
// Waafi HPP request/response types (exported for apps that build/inspect requests directly)
|
||||||
|
export type {
|
||||||
|
WaafiState,
|
||||||
|
WaafiHppPurchaseRequest,
|
||||||
|
WaafiHppPurchaseResponse,
|
||||||
|
WaafiGetTranInfoRequest,
|
||||||
|
WaafiGetTranInfoResponse,
|
||||||
|
} from './providers/waafi/waafi.types';
|
||||||
|
|
||||||
// Webhook payload types
|
// Webhook payload types
|
||||||
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
|
export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types';
|
||||||
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
|
export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types';
|
||||||
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
|
export type { EBirrWebhookPayload } from './webhooks/ebirr-webhook.types';
|
||||||
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
|
export type { CardWebhookPayload } from './webhooks/card-webhook.types';
|
||||||
export type { WaafiWebhookPayload } from './webhooks/waafi-webhook.types';
|
export type {
|
||||||
|
WaafiWebhookPayload,
|
||||||
|
WaafiWebhookTransactionPayload,
|
||||||
|
WaafiWebhookTestPayload,
|
||||||
|
WaafiWebhookHeaders,
|
||||||
|
WaafiWebhookEvent,
|
||||||
|
WaafiWebhookStatus,
|
||||||
|
} from './webhooks/waafi-webhook.types';
|
||||||
export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types';
|
export type { DMoneyWebhookPayload } from './webhooks/dmoney-webhook.types';
|
||||||
|
|
||||||
// DI token for injecting all providers as an array (future multi-provider wiring)
|
// DI token for injecting all providers as an array (future multi-provider wiring)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from "node:crypto";
|
||||||
|
|
||||||
interface CardInitiateRequest {
|
interface CardInitiateRequest {
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -54,7 +54,9 @@ export class CardProvider implements PaymentProvider {
|
|||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
async initiate(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): Promise<ProviderInitiationResult> {
|
||||||
const amount = input.amountMinor / 100;
|
const amount = input.amountMinor / 100;
|
||||||
|
|
||||||
const requestBody: CardInitiateRequest = {
|
const requestBody: CardInitiateRequest = {
|
||||||
@@ -65,7 +67,8 @@ export class CardProvider implements PaymentProvider {
|
|||||||
merchantOrderId: input.merchantOrderId,
|
merchantOrderId: input.merchantOrderId,
|
||||||
orderRef: input.orderRef,
|
orderRef: input.orderRef,
|
||||||
},
|
},
|
||||||
return_url: this.returnUrl,
|
// Per-transaction browser return target (each calling app has its own UI); config is fallback.
|
||||||
|
return_url: input.returnUrl ?? this.returnUrl,
|
||||||
webhook_url: this.webhookUrl,
|
webhook_url: this.webhookUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,14 +78,16 @@ export class CardProvider implements PaymentProvider {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!response.id) {
|
if (!response.id) {
|
||||||
throw new Error(`Card gateway initiate failed: ${JSON.stringify(response)}`);
|
throw new Error(
|
||||||
|
`Card gateway initiate failed: ${JSON.stringify(response)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt = new Date(response.expires_at * 1000);
|
const expiresAt = new Date(response.expires_at * 1000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: response.id,
|
providerOrderId: response.id,
|
||||||
clientAction: { type: 'REDIRECT', url: response.checkout_url },
|
clientAction: { type: "REDIRECT", url: response.checkout_url },
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: requestBody,
|
request: requestBody,
|
||||||
@@ -109,12 +114,15 @@ export class CardProvider implements PaymentProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyWebhookSignature(payload: Record<string, unknown>, signature: string): boolean {
|
verifyWebhookSignature(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
signature: string,
|
||||||
|
): boolean {
|
||||||
const payloadString = JSON.stringify(payload);
|
const payloadString = JSON.stringify(payload);
|
||||||
const expectedSignature = crypto
|
const expectedSignature = crypto
|
||||||
.createHmac('sha256', this.webhookSecret)
|
.createHmac("sha256", this.webhookSecret)
|
||||||
.update(payloadString)
|
.update(payloadString)
|
||||||
.digest('hex');
|
.digest("hex");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(
|
||||||
@@ -132,18 +140,18 @@ export class CardProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private mapStatus(status: string): ProviderPaymentStatus {
|
private mapStatus(status: string): ProviderPaymentStatus {
|
||||||
switch (status?.toLowerCase()) {
|
switch (status?.toLowerCase()) {
|
||||||
case 'succeeded':
|
case "succeeded":
|
||||||
case 'paid':
|
case "paid":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'failed':
|
case "failed":
|
||||||
case 'canceled':
|
case "canceled":
|
||||||
case 'expired':
|
case "expired":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'requires_payment_method':
|
case "requires_payment_method":
|
||||||
case 'requires_confirmation':
|
case "requires_confirmation":
|
||||||
case 'requires_action':
|
case "requires_action":
|
||||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'processing':
|
case "processing":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
@@ -153,8 +161,8 @@ export class CardProvider implements PaymentProvider {
|
|||||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
};
|
};
|
||||||
@@ -162,7 +170,9 @@ export class CardProvider implements PaymentProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||||
this.logger.debug(`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
this.logger.debug(
|
||||||
|
`Card Gateway POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||||
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
@@ -170,7 +180,9 @@ export class CardProvider implements PaymentProvider {
|
|||||||
`Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
`Card Gateway POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`Card Gateway POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -179,7 +191,7 @@ export class CardProvider implements PaymentProvider {
|
|||||||
private async getJson<T>(url: string): Promise<T> {
|
private async getJson<T>(url: string): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
};
|
};
|
||||||
@@ -187,7 +199,9 @@ export class CardProvider implements PaymentProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.get<T>(url, config));
|
const res = await firstValueFrom(this.http.get<T>(url, config));
|
||||||
this.logger.debug(`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
this.logger.debug(
|
||||||
|
`Card Gateway GET ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||||
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
@@ -195,25 +209,27 @@ export class CardProvider implements PaymentProvider {
|
|||||||
`Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
`Card Gateway GET ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`Card Gateway GET ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string {
|
private get baseUrl(): string {
|
||||||
return this.config.get<string>('card.baseUrl') ?? '';
|
return this.config.get<string>("card.baseUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get apiKey(): string {
|
private get apiKey(): string {
|
||||||
return this.config.get<string>('card.apiKey') ?? '';
|
return this.config.get<string>("card.apiKey") ?? "";
|
||||||
}
|
}
|
||||||
private get webhookSecret(): string {
|
private get webhookSecret(): string {
|
||||||
return this.config.get<string>('card.webhookSecret') ?? '';
|
return this.config.get<string>("card.webhookSecret") ?? "";
|
||||||
}
|
}
|
||||||
private get webhookUrl(): string {
|
private get webhookUrl(): string {
|
||||||
return this.config.get<string>('card.webhookUrl') ?? '';
|
return this.config.get<string>("card.webhookUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get returnUrl(): string {
|
private get returnUrl(): string {
|
||||||
return this.config.get<string>('card.returnUrl') ?? '';
|
return this.config.get<string>("card.returnUrl") ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from "node:crypto";
|
||||||
|
|
||||||
interface CbeBirrInitiateRequest {
|
interface CbeBirrInitiateRequest {
|
||||||
merchantId: string;
|
merchantId: string;
|
||||||
@@ -51,7 +51,9 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
async initiate(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): Promise<ProviderInitiationResult> {
|
||||||
const amount = (input.amountMinor / 100).toFixed(2);
|
const amount = (input.amountMinor / 100).toFixed(2);
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
@@ -61,7 +63,8 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
amount,
|
amount,
|
||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
description: `EDR ${input.orderRef}`,
|
description: `EDR ${input.orderRef}`,
|
||||||
returnUrl: this.returnUrl,
|
// Per-transaction browser return target (each calling app has its own UI); config is fallback.
|
||||||
|
returnUrl: input.returnUrl ?? this.returnUrl,
|
||||||
notifyUrl: this.notifyUrl,
|
notifyUrl: this.notifyUrl,
|
||||||
timestamp,
|
timestamp,
|
||||||
signature: this.signRequest({
|
signature: this.signRequest({
|
||||||
@@ -85,7 +88,7 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: response.orderId,
|
providerOrderId: response.orderId,
|
||||||
clientAction: { type: 'REDIRECT', url: response.paymentUrl },
|
clientAction: { type: "REDIRECT", url: response.paymentUrl },
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: this.sanitize(requestBody),
|
request: this.sanitize(requestBody),
|
||||||
@@ -117,14 +120,15 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
return {
|
return {
|
||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId: response.transactionId,
|
providerTxnId: response.transactionId,
|
||||||
failureCode: mapped === ProviderPaymentStatus.FAILED ? response.status : undefined,
|
failureCode:
|
||||||
|
mapped === ProviderPaymentStatus.FAILED ? response.status : undefined,
|
||||||
rawResponse: response as unknown as Record<string, unknown>,
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||||
const { signature, ...data } = payload;
|
const { signature, ...data } = payload;
|
||||||
if (!signature || typeof signature !== 'string') return false;
|
if (!signature || typeof signature !== "string") return false;
|
||||||
|
|
||||||
const expectedSignature = this.signRequest(data);
|
const expectedSignature = this.signRequest(data);
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(
|
||||||
@@ -139,16 +143,16 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private mapStatus(status: string): ProviderPaymentStatus {
|
private mapStatus(status: string): ProviderPaymentStatus {
|
||||||
switch (status?.toUpperCase()) {
|
switch (status?.toUpperCase()) {
|
||||||
case 'SUCCESS':
|
case "SUCCESS":
|
||||||
case 'COMPLETED':
|
case "COMPLETED":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'FAILED':
|
case "FAILED":
|
||||||
case 'REJECTED':
|
case "REJECTED":
|
||||||
case 'EXPIRED':
|
case "EXPIRED":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'PENDING':
|
case "PENDING":
|
||||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PROCESSING':
|
case "PROCESSING":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
@@ -157,21 +161,19 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private signRequest(data: Record<string, unknown>): string {
|
private signRequest(data: Record<string, unknown>): string {
|
||||||
const sortedKeys = Object.keys(data).sort();
|
const sortedKeys = Object.keys(data).sort();
|
||||||
const signString = sortedKeys
|
const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&");
|
||||||
.map((key) => `${key}=${data[key]}`)
|
|
||||||
.join('&');
|
|
||||||
|
|
||||||
return crypto
|
return crypto
|
||||||
.createHmac('sha256', this.secretKey)
|
.createHmac("sha256", this.secretKey)
|
||||||
.update(signString)
|
.update(signString)
|
||||||
.digest('hex');
|
.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-Merchant-Id': this.merchantId,
|
"X-Merchant-Id": this.merchantId,
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
};
|
};
|
||||||
@@ -179,7 +181,9 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||||
this.logger.debug(`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
this.logger.debug(
|
||||||
|
`CBE Birr POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||||
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
@@ -187,7 +191,9 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
`CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
`CBE Birr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`CBE Birr POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -199,18 +205,18 @@ export class CbeBirrProvider implements PaymentProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string {
|
private get baseUrl(): string {
|
||||||
return this.config.get<string>('cbe.baseUrl') ?? '';
|
return this.config.get<string>("cbe.baseUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get merchantId(): string {
|
private get merchantId(): string {
|
||||||
return this.config.get<string>('cbe.merchantId') ?? '';
|
return this.config.get<string>("cbe.merchantId") ?? "";
|
||||||
}
|
}
|
||||||
private get secretKey(): string {
|
private get secretKey(): string {
|
||||||
return this.config.get<string>('cbe.secretKey') ?? '';
|
return this.config.get<string>("cbe.secretKey") ?? "";
|
||||||
}
|
}
|
||||||
private get notifyUrl(): string {
|
private get notifyUrl(): string {
|
||||||
return this.config.get<string>('cbe.notifyUrl') ?? '';
|
return this.config.get<string>("cbe.notifyUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get returnUrl(): string {
|
private get returnUrl(): string {
|
||||||
return this.config.get<string>('cbe.returnUrl') ?? '';
|
return this.config.get<string>("cbe.returnUrl") ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
async initiate(
|
async initiate(
|
||||||
input: ProviderInitiationInput,
|
input: ProviderInitiationInput,
|
||||||
@@ -99,9 +99,9 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
clientAction: response.checkoutUrl
|
clientAction: response.checkoutUrl
|
||||||
? { type: "REDIRECT", url: response.checkoutUrl }
|
? { type: "REDIRECT", url: response.checkoutUrl }
|
||||||
: {
|
: {
|
||||||
type: "REDIRECT",
|
type: "REDIRECT",
|
||||||
url: `${this.baseUrl}/checkout/${response.orderId}`,
|
url: `${this.baseUrl}/checkout/${response.orderId}`,
|
||||||
},
|
},
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: this.sanitize(requestBody),
|
request: this.sanitize(requestBody),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from "node:crypto";
|
||||||
|
|
||||||
interface EBirrInitiateRequest {
|
interface EBirrInitiateRequest {
|
||||||
merchantCode: string;
|
merchantCode: string;
|
||||||
@@ -58,7 +58,9 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
async initiate(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): Promise<ProviderInitiationResult> {
|
||||||
const amount = input.amountMinor / 100;
|
const amount = input.amountMinor / 100;
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
@@ -70,7 +72,8 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
subject: `EDR Ticket`,
|
subject: `EDR Ticket`,
|
||||||
body: `Order ${input.orderRef}`,
|
body: `Order ${input.orderRef}`,
|
||||||
notifyUrl: this.notifyUrl,
|
notifyUrl: this.notifyUrl,
|
||||||
returnUrl: this.returnUrl,
|
// Per-transaction browser return target (each calling app has its own UI); config is fallback.
|
||||||
|
returnUrl: input.returnUrl ?? this.returnUrl,
|
||||||
timestamp,
|
timestamp,
|
||||||
sign: this.signRequest({
|
sign: this.signRequest({
|
||||||
merchantCode: this.merchantCode,
|
merchantCode: this.merchantCode,
|
||||||
@@ -85,7 +88,7 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
requestBody,
|
requestBody,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.code !== '0000' || !response.data?.orderNo) {
|
if (response.code !== "0000" || !response.data?.orderNo) {
|
||||||
throw new Error(`eBirr initiate failed: ${response.message}`);
|
throw new Error(`eBirr initiate failed: ${response.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: response.data.orderNo,
|
providerOrderId: response.data.orderNo,
|
||||||
clientAction: { type: 'REDIRECT', url: response.data.payUrl },
|
clientAction: { type: "REDIRECT", url: response.data.payUrl },
|
||||||
expiresAt,
|
expiresAt,
|
||||||
rawInitiation: {
|
rawInitiation: {
|
||||||
request: this.sanitize(requestBody),
|
request: this.sanitize(requestBody),
|
||||||
@@ -120,7 +123,7 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
requestBody,
|
requestBody,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.code !== '0000' || !response.data) {
|
if (response.code !== "0000" || !response.data) {
|
||||||
throw new Error(`eBirr query failed: ${response.message}`);
|
throw new Error(`eBirr query failed: ${response.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,20 +132,20 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
return {
|
return {
|
||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId: response.data.tradeNo,
|
providerTxnId: response.data.tradeNo,
|
||||||
failureCode: mapped === ProviderPaymentStatus.FAILED ? response.data.tradeStatus : undefined,
|
failureCode:
|
||||||
|
mapped === ProviderPaymentStatus.FAILED
|
||||||
|
? response.data.tradeStatus
|
||||||
|
: undefined,
|
||||||
rawResponse: response as unknown as Record<string, unknown>,
|
rawResponse: response as unknown as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||||
const { sign, ...data } = payload;
|
const { sign, ...data } = payload;
|
||||||
if (!sign || typeof sign !== 'string') return false;
|
if (!sign || typeof sign !== "string") return false;
|
||||||
|
|
||||||
const expectedSign = this.signRequest(data);
|
const expectedSign = this.signRequest(data);
|
||||||
return crypto.timingSafeEqual(
|
return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign));
|
||||||
Buffer.from(sign),
|
|
||||||
Buffer.from(expectedSign),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
|
mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||||
@@ -151,17 +154,17 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
|
private mapStatus(tradeStatus: string): ProviderPaymentStatus {
|
||||||
switch (tradeStatus?.toUpperCase()) {
|
switch (tradeStatus?.toUpperCase()) {
|
||||||
case 'TRADE_SUCCESS':
|
case "TRADE_SUCCESS":
|
||||||
case 'SUCCESS':
|
case "SUCCESS":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'TRADE_CLOSED':
|
case "TRADE_CLOSED":
|
||||||
case 'TRADE_FAILED':
|
case "TRADE_FAILED":
|
||||||
case 'FAILED':
|
case "FAILED":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'WAIT_BUYER_PAY':
|
case "WAIT_BUYER_PAY":
|
||||||
case 'PENDING':
|
case "PENDING":
|
||||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PROCESSING':
|
case "PROCESSING":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
@@ -170,21 +173,21 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
private signRequest(data: Record<string, unknown>): string {
|
private signRequest(data: Record<string, unknown>): string {
|
||||||
const sortedKeys = Object.keys(data).sort();
|
const sortedKeys = Object.keys(data).sort();
|
||||||
const signString = sortedKeys
|
const signString =
|
||||||
.map((key) => `${key}=${data[key]}`)
|
sortedKeys.map((key) => `${key}=${data[key]}`).join("&") +
|
||||||
.join('&') + `&key=${this.secretKey}`;
|
`&key=${this.secretKey}`;
|
||||||
|
|
||||||
return crypto
|
return crypto
|
||||||
.createHash('md5')
|
.createHash("md5")
|
||||||
.update(signString)
|
.update(signString)
|
||||||
.digest('hex')
|
.digest("hex")
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
private async postJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
const config: AxiosRequestConfig = {
|
const config: AxiosRequestConfig = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
};
|
};
|
||||||
@@ -192,7 +195,9 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||||
this.logger.debug(`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
this.logger.debug(
|
||||||
|
`eBirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||||
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
@@ -200,7 +205,9 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
`eBirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`eBirr POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -212,18 +219,18 @@ export class EBirrProvider implements PaymentProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string {
|
private get baseUrl(): string {
|
||||||
return this.config.get<string>('ebirr.baseUrl') ?? '';
|
return this.config.get<string>("ebirr.baseUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get merchantCode(): string {
|
private get merchantCode(): string {
|
||||||
return this.config.get<string>('ebirr.merchantCode') ?? '';
|
return this.config.get<string>("ebirr.merchantCode") ?? "";
|
||||||
}
|
}
|
||||||
private get secretKey(): string {
|
private get secretKey(): string {
|
||||||
return this.config.get<string>('ebirr.secretKey') ?? '';
|
return this.config.get<string>("ebirr.secretKey") ?? "";
|
||||||
}
|
}
|
||||||
private get notifyUrl(): string {
|
private get notifyUrl(): string {
|
||||||
return this.config.get<string>('ebirr.notifyUrl') ?? '';
|
return this.config.get<string>("ebirr.notifyUrl") ?? "";
|
||||||
}
|
}
|
||||||
private get returnUrl(): string {
|
private get returnUrl(): string {
|
||||||
return this.config.get<string>('ebirr.returnUrl') ?? '';
|
return this.config.get<string>("ebirr.returnUrl") ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
PaymentProvider,
|
PaymentProvider,
|
||||||
ProviderInitiationInput,
|
ProviderInitiationInput,
|
||||||
@@ -8,22 +8,22 @@ import {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
ProviderPaymentStatus,
|
ProviderPaymentStatus,
|
||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from '@edr/types';
|
} from "@edr/types";
|
||||||
import { AxiosError, AxiosRequestConfig } from 'axios';
|
import { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from "rxjs";
|
||||||
import * as https from 'node:https';
|
import * as https from "node:https";
|
||||||
import {
|
import {
|
||||||
createNonceStr,
|
createNonceStr,
|
||||||
createTimestamp,
|
createTimestamp,
|
||||||
signRequestObject,
|
signRequestObject,
|
||||||
verifyRequestObject,
|
verifyRequestObject,
|
||||||
} from './telebirr.crypto';
|
} from "./telebirr.crypto";
|
||||||
import {
|
import {
|
||||||
CreateOrderRequest,
|
CreateOrderRequest,
|
||||||
CreateOrderResponse,
|
CreateOrderResponse,
|
||||||
FabricTokenResponse,
|
FabricTokenResponse,
|
||||||
QueryOrderResponse,
|
QueryOrderResponse,
|
||||||
} from './telebirr.types';
|
} from "./telebirr.types";
|
||||||
|
|
||||||
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
@@ -37,17 +37,21 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly http: HttpService,
|
private readonly http: HttpService,
|
||||||
) {
|
) {
|
||||||
const insecure = this.config.get<boolean>('telebirr.insecureTls');
|
const insecure = this.config.get<boolean>("telebirr.insecureTls");
|
||||||
if (insecure) {
|
if (insecure) {
|
||||||
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
|
this.logger.warn(
|
||||||
|
"TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.httpsAgent = new https.Agent({
|
this.httpsAgent = new https.Agent({
|
||||||
rejectUnauthorized: !insecure,
|
rejectUnauthorized: !insecure,
|
||||||
secureProtocol: 'TLSv1_2_method',
|
secureProtocol: "TLSv1_2_method",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
|
async initiate(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): Promise<ProviderInitiationResult> {
|
||||||
const fabricToken = await this.applyFabricToken();
|
const fabricToken = await this.applyFabricToken();
|
||||||
const requestBody = this.buildCreateOrderRequest(input);
|
const requestBody = this.buildCreateOrderRequest(input);
|
||||||
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
const response = await this.requestCreateOrder(fabricToken, requestBody);
|
||||||
@@ -59,17 +63,19 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
|
const expiresAt = this.computeExpiresAt(
|
||||||
const platform = input.platform ?? 'web';
|
requestBody.biz_content.timeout_express,
|
||||||
|
);
|
||||||
|
const platform = input.platform ?? "web";
|
||||||
const clientAction =
|
const clientAction =
|
||||||
platform === 'mobile'
|
platform === "mobile"
|
||||||
? {
|
? {
|
||||||
type: 'LAUNCH_APP' as const,
|
type: "LAUNCH_APP" as const,
|
||||||
appId: this.merchantAppId,
|
appId: this.merchantAppId,
|
||||||
receiveCode: response.biz_content?.receiveCode,
|
receiveCode: response.biz_content?.receiveCode,
|
||||||
shortCode: this.merchantCode,
|
shortCode: this.merchantCode,
|
||||||
}
|
}
|
||||||
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
|
: { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerOrderId: prepayId,
|
providerOrderId: prepayId,
|
||||||
@@ -89,8 +95,8 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
|
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
|
||||||
requestBody,
|
requestBody,
|
||||||
{
|
{
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-APP-Key': this.fabricAppId,
|
"X-APP-Key": this.fabricAppId,
|
||||||
Authorization: fabricToken,
|
Authorization: fabricToken,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,36 +110,40 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
status: mapped,
|
status: mapped,
|
||||||
providerTxnId,
|
providerTxnId,
|
||||||
failureCode:
|
failureCode:
|
||||||
mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined,
|
mapped === ProviderPaymentStatus.FAILED && tradeStatus
|
||||||
|
? tradeStatus
|
||||||
|
: undefined,
|
||||||
rawResponse: response as Record<string, unknown>,
|
rawResponse: response as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
||||||
switch (tradeStatus) {
|
switch (tradeStatus) {
|
||||||
case 'PAY_SUCCESS':
|
case "PAY_SUCCESS":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'PAY_FAILED':
|
case "PAY_FAILED":
|
||||||
case 'ORDER_CLOSED':
|
case "ORDER_CLOSED":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'WAIT_PAY':
|
case "WAIT_PAY":
|
||||||
return ProviderPaymentStatus.REQUIRES_ACTION;
|
return ProviderPaymentStatus.REQUIRES_ACTION;
|
||||||
case 'PAYING':
|
case "PAYING":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus {
|
mapWebhookTradeStatus(
|
||||||
|
tradeStatus: string | undefined,
|
||||||
|
): ProviderPaymentStatus {
|
||||||
switch (tradeStatus) {
|
switch (tradeStatus) {
|
||||||
case 'Completed':
|
case "Completed":
|
||||||
return ProviderPaymentStatus.SUCCEEDED;
|
return ProviderPaymentStatus.SUCCEEDED;
|
||||||
case 'Failure':
|
case "Failure":
|
||||||
case 'Expired':
|
case "Expired":
|
||||||
return ProviderPaymentStatus.FAILED;
|
return ProviderPaymentStatus.FAILED;
|
||||||
case 'Paying':
|
case "Paying":
|
||||||
case 'Pending':
|
case "Pending":
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
default:
|
default:
|
||||||
return ProviderPaymentStatus.PROCESSING;
|
return ProviderPaymentStatus.PROCESSING;
|
||||||
@@ -142,7 +152,9 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
|
|
||||||
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
|
||||||
if (!this.publicKey) {
|
if (!this.publicKey) {
|
||||||
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
|
this.logger.error(
|
||||||
|
"TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return verifyRequestObject(payload, this.publicKey);
|
return verifyRequestObject(payload, this.publicKey);
|
||||||
@@ -153,12 +165,14 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
`${this.baseUrl}/payment/v1/token`,
|
`${this.baseUrl}/payment/v1/token`,
|
||||||
{ appSecret: this.appSecret },
|
{ appSecret: this.appSecret },
|
||||||
{
|
{
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-APP-Key': this.fabricAppId,
|
"X-APP-Key": this.fabricAppId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response?.token) {
|
if (!response?.token) {
|
||||||
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
|
throw new Error(
|
||||||
|
`Telebirr token request failed: ${JSON.stringify(response)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return response.token;
|
return response.token;
|
||||||
}
|
}
|
||||||
@@ -171,51 +185,61 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
`${this.baseUrl}/payment/v1/inapp/createOrder`,
|
`${this.baseUrl}/payment/v1/inapp/createOrder`,
|
||||||
body,
|
body,
|
||||||
{
|
{
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'X-APP-Key': this.fabricAppId,
|
"X-APP-Key": this.fabricAppId,
|
||||||
Authorization: fabricToken,
|
Authorization: fabricToken,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
|
private buildCreateOrderRequest(
|
||||||
|
input: ProviderInitiationInput,
|
||||||
|
): CreateOrderRequest {
|
||||||
const totalAmount = String(input.amountMinor / 100);
|
const totalAmount = String(input.amountMinor / 100);
|
||||||
const req = {
|
const req = {
|
||||||
timestamp: createTimestamp(),
|
timestamp: createTimestamp(),
|
||||||
nonce_str: createNonceStr(),
|
nonce_str: createNonceStr(),
|
||||||
method: 'payment.preorder' as const,
|
method: "payment.preorder" as const,
|
||||||
version: '1.0' as const,
|
version: "1.0" as const,
|
||||||
biz_content: {
|
biz_content: {
|
||||||
notify_url: this.notifyUrl,
|
notify_url: this.notifyUrl,
|
||||||
appid: this.merchantAppId,
|
appid: this.merchantAppId,
|
||||||
merch_code: this.merchantCode,
|
merch_code: this.merchantCode,
|
||||||
merch_order_id: input.merchantOrderId,
|
merch_order_id: input.merchantOrderId,
|
||||||
trade_type: 'Checkout' as const,
|
trade_type: "Checkout" as const,
|
||||||
title: `EDR ${input.orderRef}`,
|
title: `EDR ${input.orderRef}`,
|
||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: input.currency,
|
trans_currency: input.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
redirect_url: input.redirectUrl
|
redirect_url: input.redirectUrl,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
|
const sign = signRequestObject(
|
||||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
req as unknown as Record<string, unknown>,
|
||||||
|
this.privateKey,
|
||||||
|
);
|
||||||
|
return { ...req, sign, sign_type: "SHA256WithRSA" };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
|
private buildQueryOrderRequest(
|
||||||
|
merchantOrderId: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
const req = {
|
const req = {
|
||||||
timestamp: createTimestamp(),
|
timestamp: createTimestamp(),
|
||||||
nonce_str: createNonceStr(),
|
nonce_str: createNonceStr(),
|
||||||
method: 'payment.queryorder',
|
method: "payment.queryorder",
|
||||||
version: '1.0',
|
version: "1.0",
|
||||||
biz_content: {
|
biz_content: {
|
||||||
appid: this.merchantAppId,
|
appid: this.merchantAppId,
|
||||||
merch_code: this.merchantCode,
|
merch_code: this.merchantCode,
|
||||||
merch_order_id: merchantOrderId,
|
merch_order_id: merchantOrderId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
|
const sign = signRequestObject(
|
||||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
req as Record<string, unknown>,
|
||||||
|
this.privateKey,
|
||||||
|
);
|
||||||
|
return { ...req, sign, sign_type: "SHA256WithRSA" };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCheckoutUrl(prepayId: string): string {
|
private buildCheckoutUrl(prepayId: string): string {
|
||||||
@@ -233,27 +257,34 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
`nonce_str=${map.nonce_str}`,
|
`nonce_str=${map.nonce_str}`,
|
||||||
`prepay_id=${map.prepay_id}`,
|
`prepay_id=${map.prepay_id}`,
|
||||||
`timestamp=${map.timestamp}`,
|
`timestamp=${map.timestamp}`,
|
||||||
'sign_type=SHA256WithRSA',
|
"sign_type=SHA256WithRSA",
|
||||||
`sign=${sign}`,
|
`sign=${sign}`,
|
||||||
'version=1.0',
|
"version=1.0",
|
||||||
'trade_type=Checkout',
|
"trade_type=Checkout",
|
||||||
].join('&');
|
].join("&");
|
||||||
return `${this.webBaseUrl}${rawRequest}`;
|
return `${this.webBaseUrl}${rawRequest}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeExpiresAt(timeoutExpress: string): Date {
|
private computeExpiresAt(timeoutExpress: string): Date {
|
||||||
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
|
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
|
||||||
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
|
const minutes = match
|
||||||
|
? this.toMinutes(parseInt(match[1], 10), match[2])
|
||||||
|
: 15;
|
||||||
return new Date(Date.now() + minutes * 60_000);
|
return new Date(Date.now() + minutes * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toMinutes(n: number, unit: string): number {
|
private toMinutes(n: number, unit: string): number {
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case 's': return Math.max(1, Math.round(n / 60));
|
case "s":
|
||||||
case 'm': return n;
|
return Math.max(1, Math.round(n / 60));
|
||||||
case 'h': return n * 60;
|
case "m":
|
||||||
case 'd': return n * 60 * 24;
|
return n;
|
||||||
default: return 15;
|
case "h":
|
||||||
|
return n * 60;
|
||||||
|
case "d":
|
||||||
|
return n * 60 * 24;
|
||||||
|
default:
|
||||||
|
return 15;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +301,9 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
const res = await firstValueFrom(this.http.post<T>(url, body, config));
|
||||||
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
|
this.logger.debug(
|
||||||
|
`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`,
|
||||||
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
@@ -278,7 +311,9 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
|
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
|
this.logger.error(
|
||||||
|
`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -289,14 +324,34 @@ export class TelebirrProvider implements PaymentProvider {
|
|||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
|
private get baseUrl(): string {
|
||||||
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
|
return this.config.get<string>("telebirr.baseUrl") ?? "";
|
||||||
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
|
}
|
||||||
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
|
private get webBaseUrl(): string {
|
||||||
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
|
return this.config.get<string>("telebirr.webBaseUrl") ?? "";
|
||||||
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
|
}
|
||||||
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
|
private get fabricAppId(): string {
|
||||||
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
|
return this.config.get<string>("telebirr.fabricAppId") ?? "";
|
||||||
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
|
}
|
||||||
private get publicKey(): string { return this.config.get<string>('telebirr.publicKey') ?? ''; }
|
private get appSecret(): string {
|
||||||
|
return this.config.get<string>("telebirr.appSecret") ?? "";
|
||||||
|
}
|
||||||
|
private get merchantAppId(): string {
|
||||||
|
return this.config.get<string>("telebirr.merchantAppId") ?? "";
|
||||||
|
}
|
||||||
|
private get merchantCode(): string {
|
||||||
|
return this.config.get<string>("telebirr.merchantCode") ?? "";
|
||||||
|
}
|
||||||
|
private get notifyUrl(): string {
|
||||||
|
return this.config.get<string>("telebirr.notifyUrl") ?? "";
|
||||||
|
}
|
||||||
|
private get timeoutExpress(): string {
|
||||||
|
return this.config.get<string>("telebirr.timeoutExpress") ?? "15m";
|
||||||
|
}
|
||||||
|
private get privateKey(): string {
|
||||||
|
return this.config.get<string>("telebirr.privateKey") ?? "";
|
||||||
|
}
|
||||||
|
private get publicKey(): string {
|
||||||
|
return this.config.get<string>("telebirr.publicKey") ?? "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user