diff --git a/.github/scripts/scan.js b/.github/scripts/scan.js new file mode 100644 index 000000000..7b1924b3f --- /dev/null +++ b/.github/scripts/scan.js @@ -0,0 +1,583 @@ +/** + * PolinRider / Famous Chollima Supply-Chain Malware Scanner + * + * Detects the specific injection pattern used in the PolinRider campaign + * attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342). + * + * IOCs sourced from: + * - Direct analysis of the injected tailwind.config.js sample + * - Socket Security report (May 2026) on roberts/leads compromise + * - PolinRider technical analysis report (Trend Micro / safedep.io) + * + * Zero external dependencies — runs on any Node.js >= 14. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +// ─── Configuration ──────────────────────────────────────────────────────────── + +const CONFIG = { + // Maximum legitimate size for JS config files. + // Real tailwind/postcss/babel configs are rarely > 3 KB. + // Injected files jump to 5–8 KB instantly. + maxLegitConfigBytes: 3072, + + // Minimum whitespace run on a single line that signals hidden payload. + // The campaign uses ~280–510 spaces to push payload off-screen. + minSuspiciousInlineSpaces: 100, + + // Files that are high-value injection targets for this campaign. + targetedFilenames: [ + "tailwind.config.js", + "tailwind.config.ts", + "tailwind.config.cjs", + "tailwind.js", + "postcss.config.js", + "postcss.config.mjs", + "postcss.config.cjs", + "babel.config.js", + "babel.config.cjs", + "next.config.js", + "next.config.mjs", + "next.config.cjs", + "astro.config.mjs", + "astro.config.js", + "vite.config.js", + "vite.config.ts", + "webpack.config.js", + "webpack.mix.js", + ], + + // Files intentionally containing malware indicators for scanner logic/tests. + // These filenames are skipped before malware rules are evaluated. + ignoredFilenames: ["scan.js"], + + // Filesystem paths that indicate active persistence mechanisms + persistenceArtifacts: [ + "temp_auto_push.bat", + "temp_interactive_push.bat", + "branch_structure.json", + // Note: queue.bat and .plist are OS-level; checked separately + ], +}; + +// ─── IOC Definitions ────────────────────────────────────────────────────────── + +/** + * Each rule has: + * id – unique rule identifier for reporting + * severity – CRITICAL | HIGH | MEDIUM + * description – human-readable explanation + * test(content, filePath, lines) – returns array of match details or [] + */ +const RULES = [ + // ── Tier 1: Definitive campaign signatures ───────────────────────────────── + + { + id: "POLINRIDER-001", + severity: "CRITICAL", + description: + "PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign", + test(content) { + const matches = []; + const re = /_\$_1e42/g; + let m; + while ((m = re.exec(content)) !== null) { + matches.push(`offset ${m.index}`); + } + return matches; + }, + }, + + { + id: "POLINRIDER-002", + severity: "CRITICAL", + description: + "PolinRider campaign marker global['!'] assignment — used to route C2 traffic", + test(content) { + const matches = []; + const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g; + let m; + while ((m = re.exec(content)) !== null) { + const snippet = content + .slice(m.index, m.index + 40) + .replace(/\n/g, "\\n"); + matches.push(`"${snippet}"`); + } + return matches; + }, + }, + + { + id: "POLINRIDER-003", + severity: "CRITICAL", + description: + 'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo', + test(content) { + return content.includes("rmcej%otb%") ? ["seed string found"] : []; + }, + }, + + { + id: "POLINRIDER-004", + severity: "CRITICAL", + description: + "Known C2 IP addresses associated with PolinRider infrastructure", + test(content) { + const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"]; + return knownC2.filter((ip) => content.includes(ip)); + }, + }, + + { + id: "POLINRIDER-005", + severity: "CRITICAL", + description: + "Known TRON blockchain wallet addresses used as dead-drop C2 resolvers", + test(content) { + const wallets = [ + "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP", + "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG", + ]; + return wallets.filter((w) => content.includes(w)); + }, + }, + + { + id: "POLINRIDER-006", + severity: "CRITICAL", + description: + "Known Aptos blockchain addresses used as fallback dead-drop resolvers", + test(content) { + const addrs = [ + "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e", + "0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3", + ]; + return addrs.filter((a) => content.includes(a)); + }, + }, + + { + id: "POLINRIDER-007", + severity: "CRITICAL", + description: + "Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions", + test(content) { + const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"]; + return keys.filter((k) => content.includes(k)); + }, + }, + + { + id: "POLINRIDER-008", + severity: "CRITICAL", + description: + "Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)", + test(content) { + const knownHashes = new Set([ + "96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3", + "522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f", + ]); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : []; + }, + }, + + // ── Tier 2: Behavioral / structural indicators ───────────────────────────── + + { + id: "POLINRIDER-009", + severity: "HIGH", + description: + "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers", + test(content) { + const endpoints = [ + "trongrid.io", + "aptoslabs.com", + "bsc-dataseed.binance.org", + "bsc-rpc.publicnode.com", + "eth_getTransactionByHash", + ]; + return endpoints.filter((e) => content.includes(e)); + }, + }, + + { + id: "POLINRIDER-010", + severity: "HIGH", + description: + "Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly", + test(content) { + return /windowsHide\s*:\s*true/.test(content) + ? ["windowsHide:true found"] + : []; + }, + }, + + { + id: "POLINRIDER-011", + severity: "HIGH", + description: + "Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements", + test(content) { + const matches = + content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || []; + return matches.length >= 2 + ? [`Found ${matches.length} duplicate createRequire imports`] + : []; + }, + }, + + { + id: "POLINRIDER-012", + severity: "HIGH", + description: + "Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views", + test(content, _filePath, lines) { + const hits = []; + lines.forEach((line, i) => { + const spaceRun = line.match(/\s{100,}/); + if (spaceRun) { + hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`); + } + }); + return hits; + }, + }, + + { + id: "POLINRIDER-013", + severity: "HIGH", + description: + "Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 5–8 KB", + test(content, filePath) { + const bytes = Buffer.byteLength(content, "utf8"); + const base = path.basename(filePath).toLowerCase(); + const isTargeted = CONFIG.targetedFilenames.some( + (f) => f.toLowerCase() === base, + ); + if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) { + return [ + `${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`, + ]; + } + return []; + }, + }, + + { + id: "POLINRIDER-014", + severity: "HIGH", + description: + "Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches", + test(_content, filePath) { + const base = path.basename(filePath); + return CONFIG.persistenceArtifacts.includes(base) ? [base] : []; + }, + }, + + // ── Tier 3: Supporting behavioral indicators ─────────────────────────────── + + { + id: "POLINRIDER-015", + severity: "MEDIUM", + description: + "Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)", + test(content) { + const matches = []; + // Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928' + const re = + /global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g; + let m; + while ((m = re.exec(content)) !== null) { + matches.push(`marker value: "${m[1]}"`); + } + return matches; + }, + }, + + { + id: "POLINRIDER-016", + severity: "MEDIUM", + description: + "sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant", + test(content) { + // sfL appears as a named function used to decode the larger payload blob + const occurrences = (content.match(/\bsfL\b/g) || []).length; + return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : []; + }, + }, + + { + id: "POLINRIDER-017", + severity: "MEDIUM", + description: + "global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions", + test(content) { + const hits = []; + if (/global\s*\[.*\]\s*=\s*require/.test(content)) + hits.push("global[x] = require"); + if (/global\s*\[.*module.*\]\s*=\s*module/.test(content)) + hits.push("global[x] = module"); + return hits; + }, + }, +]; + +// ─── Scanner Engine ──────────────────────────────────────────────────────────── + +function scanFile(filePath) { + if (shouldIgnoreFile(filePath)) { + return { filePath, findings: [], skipped: true }; + } + + let content; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (err) { + return { filePath, error: err.message, findings: [] }; + } + + const lines = content.split("\n"); + const findings = []; + + for (const rule of RULES) { + let matches; + try { + matches = rule.test(content, filePath, lines); + } catch (err) { + matches = [`[rule error: ${err.message}]`]; + } + + if (matches && matches.length > 0) { + findings.push({ + id: rule.id, + severity: rule.severity, + description: rule.description, + matches, + }); + } + } + + return { filePath, findings }; +} + +function shouldIgnoreFile(filePath) { + const base = path.basename(filePath).toLowerCase(); + return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base); +} + +function walkDir(dir, results = []) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkDir(full, results); + } else if (entry.isFile() && !shouldIgnoreFile(full)) { + const ext = path.extname(entry.name).toLowerCase(); + const base = entry.name.toLowerCase(); + + // Scan all JS/TS config files + any file matching a targeted name + const isTargetedName = CONFIG.targetedFilenames.some( + (f) => f.toLowerCase() === base, + ); + const isPersistenceArtifact = CONFIG.persistenceArtifacts.some( + (f) => f.toLowerCase() === base, + ); + const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes( + ext, + ); + + if (isTargetedName || isPersistenceArtifact || isJsLike) { + results.push(full); + } + } + } + + return results; +} + +// ─── Reporting ───────────────────────────────────────────────────────────────── + +const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 }; +const ANSI = { + reset: "\x1b[0m", + bold: "\x1b[1m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + green: "\x1b[32m", + dim: "\x1b[2m", +}; + +function colorSeverity(sev) { + if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`; + if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`; + return `${ANSI.cyan}${sev}${ANSI.reset}`; +} + +function printReport(allResults, { json = false, outputFile = null } = {}) { + const infected = allResults.filter( + (r) => r.findings && r.findings.length > 0, + ); + const errors = allResults.filter((r) => r.error); + + if (json) { + const report = { + scannedAt: new Date().toISOString(), + totalFilesScanned: allResults.length, + infectedFiles: infected.length, + results: infected, + errors, + }; + const out = JSON.stringify(report, null, 2); + if (outputFile) { + fs.writeFileSync(outputFile, out); + console.log(`JSON report written to: ${outputFile}`); + } else { + console.log(out); + } + return infected.length > 0; + } + + // Human-readable output + console.log( + `\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`, + ); + console.log(`║ PolinRider / Famous Chollima Malware Scanner ║`); + console.log( + `╚══════════════════════════════════════════════════════════╝${ANSI.reset}`, + ); + console.log( + `${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`, + ); + + if (infected.length === 0) { + console.log( + `${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`, + ); + } else { + console.log( + `${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`, + ); + + for (const result of infected) { + // Sort findings by severity descending + const sorted = [...result.findings].sort( + (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity], + ); + const topSev = sorted[0].severity; + + console.log( + ` ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`, + ); + for (const f of sorted) { + console.log( + ` ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)} — ${f.description}`, + ); + for (const m of f.matches) { + console.log(` → ${m}`); + } + } + console.log(); + } + + console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`); + console.log( + ` 1. Immediately isolate the affected machine from the network.`, + ); + console.log( + ` 2. Do NOT run npm install, npm build, or any script on this repo.`, + ); + console.log( + ` 3. Check for running node.exe / node processes with obfuscated args.`, + ); + console.log( + ` 4. Remove all code after the legitimate config closing block.`, + ); + console.log( + ` 5. Remove duplicate 'import { createRequire }' lines at file top.`, + ); + console.log( + ` 6. Recover the git repository from a clean local clone (see docs).`, + ); + console.log( + ` 7. Revoke ALL secrets, tokens, and credentials in .env and CI.`, + ); + console.log( + ` 8. See full remediation guide in the attached incident report.\n`, + ); + } + + if (errors.length > 0) { + console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`); + for (const e of errors) { + console.log(` ${e.filePath}: ${e.error}`); + } + console.log(); + } + + return infected.length > 0; +} + +// ─── CLI Entry Point ─────────────────────────────────────────────────────────── + +function main() { + const args = process.argv.slice(2); + const jsonFlag = args.includes("--json"); + const outputFileIdx = args.indexOf("--output"); + const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null; + + // Positional args after flags are scan targets + const targets = args.filter( + (a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output", + ); + + if (targets.length === 0) { + console.error( + "Usage: scan.js [--json] [--output report.json] [path...]", + ); + console.error( + " path can be a file or directory (directories are walked recursively)", + ); + process.exit(1); + } + + const filesToScan = []; + for (const target of targets) { + if (!fs.existsSync(target)) { + console.error(`Path not found: ${target}`); + process.exit(1); + } + const stat = fs.statSync(target); + if (stat.isDirectory()) { + const found = walkDir(target); + filesToScan.push(...found); + } else { + filesToScan.push(target); + } + } + + // Deduplicate + const unique = [...new Set(filesToScan)]; + const allResults = unique.map(scanFile); + + const infected = printReport(allResults, { json: jsonFlag, outputFile }); + + // Exit code 1 if any infection found — used by CI to block deployments + process.exit(infected ? 1 : 0); +} + +main(); diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a66063ce1..e46409ba6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,6 +38,9 @@ jobs: - project: edr-passenger build_env_file: passenger-web.build.env service: passenger-backoffice + - project: edr-payment + build_env_file: payment-web.build.env + service: payment-api env: PROJECT: ${{ matrix.project }} BRANCH: ${{ github.ref_name }} diff --git a/.github/workflows/polinrider-scan.yml b/.github/workflows/polinrider-scan.yml new file mode 100644 index 000000000..594f181d1 --- /dev/null +++ b/.github/workflows/polinrider-scan.yml @@ -0,0 +1,243 @@ +name: PolinRider Malware Scan + +# ── Triggers ────────────────────────────────────────────────────────────────── +# Runs on every push and every PR targeting main/master/develop. +# Also available as a manual trigger (workflow_dispatch) and on a nightly +# schedule so dormant infections in older branches are caught too. +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + schedule: + # Nightly full-repo scan at 02:00 UTC + - cron: "0 2 * * *" + workflow_dispatch: + +# ── Permissions ─────────────────────────────────────────────────────────────── +permissions: + contents: read # checkout + security-events: write # upload SARIF to GitHub Security tab + actions: read + checks: write # annotate PRs with scan findings + +# ── Deployment gate ─────────────────────────────────────────────────────────── +# All other jobs (build, test, deploy) should list this job under `needs:`. +# If this job fails (exit code 1 from the scanner), the whole workflow stops. +jobs: + polinrider-scan: + name: "PolinRider / Famous Chollima Scan" + runs-on: ubuntu-latest + # Prevent CI from being disabled by any workflow override + if: always() + + steps: + # ── 1. Checkout full history ───────────────────────────────────────────── + # Full depth so we can inspect recent commits for temp_auto_push.bat traces + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── 2. Detect suspicious force-push patterns in git history ────────────── + - name: Check git history for force-push and timestamp manipulation + id: git-check + shell: bash + run: | + echo "=== Checking for suspicious git history patterns ===" + + # Check for .gitignore entries hiding known malware artifacts + GITIGNORE_HITS=0 + if [ -f .gitignore ]; then + for pattern in "branch_structure.json" "temp_auto_push.bat" "temp_interactive_push.bat"; do + if grep -qF "$pattern" .gitignore 2>/dev/null; then + echo "::warning file=.gitignore::SUSPICIOUS: .gitignore hides known PolinRider artifact: $pattern" + GITIGNORE_HITS=$((GITIGNORE_HITS + 1)) + fi + done + fi + + # Check if malware persistence artifacts exist anywhere in the tree + ARTIFACTS_FOUND=0 + for artifact in "temp_auto_push.bat" "temp_interactive_push.bat" "branch_structure.json"; do + FOUND=$(find . -name "$artifact" -not -path "./.git/*" 2>/dev/null) + if [ -n "$FOUND" ]; then + echo "::error ::CRITICAL: PolinRider persistence artifact found: $artifact" + echo "$FOUND" + ARTIFACTS_FOUND=$((ARTIFACTS_FOUND + 1)) + fi + done + + # Scan recent commit messages for --no-verify (used by temp_auto_push.bat) + NO_VERIFY_COMMITS=$(git log --oneline -50 --format="%H %s" 2>/dev/null | grep -i "no.verify\|force.*push\|amend" || true) + if [ -n "$NO_VERIFY_COMMITS" ]; then + echo "::warning ::Recent commits with suspicious metadata (--no-verify / force amend patterns):" + echo "$NO_VERIFY_COMMITS" + fi + + # Check for .woff2 files with unusually large sizes (>50KB is suspicious) + find . -name "*.woff2" -not -path "./.git/*" -size +50k 2>/dev/null | while read f; do + SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0) + echo "::warning file=$f::Oversized .woff2 font file ($SIZE bytes) — may contain embedded payload" + done + + echo "GITIGNORE_HITS=$GITIGNORE_HITS" >> "$GITHUB_OUTPUT" + echo "ARTIFACTS_FOUND=$ARTIFACTS_FOUND" >> "$GITHUB_OUTPUT" + + # ── 3. Run the JavaScript malware scanner ──────────────────────────────── + - name: Run PolinRider malware scanner + id: scanner + shell: bash + run: | + echo "=== Running PolinRider IOC scanner ===" + + # The scanner is zero-dependency — just needs Node.js (always present on ubuntu-latest) + node .github/scripts/scan.js \ + --json \ + --output scan-report.json \ + . + + SCANNER_EXIT=$? + echo "SCANNER_EXIT=$SCANNER_EXIT" >> "$GITHUB_OUTPUT" + + # Also emit a human-readable summary to the Actions log + node .github/scripts/scan.js . || true + + exit $SCANNER_EXIT + + # ── 4. Upload scan report as artifact ──────────────────────────────────── + # - name: Upload scan report + # if: always() + # uses: actions/upload-artifact@v4 + # with: + # name: polinrider-scan-report + # path: scan-report.json + # retention-days: 90 + + # # ── 5. Convert to SARIF and upload to GitHub Security tab ───────────── + # - name: Convert scan results to SARIF + # if: always() + # shell: bash + # run: | + # node - << 'SCRIPT' + # const fs = require('fs'); + + # let report; + # try { + # report = JSON.parse(fs.readFileSync('scan-report.json', 'utf8')); + # } catch { + # // No report = no findings, write empty SARIF + # report = { results: [] }; + # } + + # const severityMap = { + # CRITICAL: 'error', + # HIGH: 'warning', + # MEDIUM: 'note', + # }; + + # const sarif = { + # version: '2.1.0', + # $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json', + # runs: [{ + # tool: { + # driver: { + # name: 'PolinRider Malware Scanner', + # version: '1.0.0', + # informationUri: 'https://github.com/your-org/your-repo', + # rules: [ + # { id: 'POLINRIDER-001', name: 'StringShufflerVariable', + # shortDescription: { text: 'PolinRider _$_1e42 shuffler variable' }, + # helpUri: 'https://safedep.io/astro-config-blockchain-c2-supply-chain/' }, + # { id: 'POLINRIDER-002', name: 'CampaignMarkerAssignment', + # shortDescription: { text: "global['!'] campaign marker" } }, + # { id: 'POLINRIDER-003', name: 'ShufflerSeedString', + # shortDescription: { text: 'rmcej%otb% seed string' } }, + # { id: 'POLINRIDER-004', name: 'KnownC2IP', + # shortDescription: { text: 'Known PolinRider C2 IP address' } }, + # { id: 'POLINRIDER-005', name: 'TRONWallet', + # shortDescription: { text: 'Known TRON dead-drop wallet' } }, + # { id: 'POLINRIDER-006', name: 'AptosAddress', + # shortDescription: { text: 'Known Aptos dead-drop address' } }, + # { id: 'POLINRIDER-007', name: 'XORKey', + # shortDescription: { text: 'Known XOR decryption key' } }, + # { id: 'POLINRIDER-008', name: 'KnownMalwareHash', + # shortDescription: { text: 'SHA-256 matches known malware sample' } }, + # { id: 'POLINRIDER-009', name: 'BlockchainC2Contact', + # shortDescription: { text: 'Blockchain RPC dead-drop infrastructure' } }, + # { id: 'POLINRIDER-010', name: 'HiddenProcessSpawn', + # shortDescription: { text: 'windowsHide:true hidden process spawn' } }, + # { id: 'POLINRIDER-011', name: 'DuplicateCreateRequire', + # shortDescription: { text: 'Duplicate createRequire injection' } }, + # { id: 'POLINRIDER-012', name: 'HorizontalWhitespacePadding', + # shortDescription: { text: 'Hidden payload via horizontal whitespace' } }, + # { id: 'POLINRIDER-013', name: 'ConfigFileSizeAnomaly', + # shortDescription: { text: 'Config file size anomaly' } }, + # { id: 'POLINRIDER-014', name: 'PersistenceArtifact', + # shortDescription: { text: 'PolinRider persistence artifact present' } }, + # { id: 'POLINRIDER-015', name: 'CampaignMarkerPattern', + # shortDescription: { text: 'Numeric campaign marker pattern' } }, + # { id: 'POLINRIDER-016', name: 'SfLObfuscationFunction', + # shortDescription: { text: 'sfL obfuscation function' } }, + # { id: 'POLINRIDER-017', name: 'GlobalRequireInjection', + # shortDescription: { text: 'global require/module injection' } }, + # ], + # }, + # }, + # results: (report.results || []).flatMap(file => + # (file.findings || []).map(finding => ({ + # ruleId: finding.id, + # level: severityMap[finding.severity] || 'warning', + # message: { text: finding.description + ' — ' + finding.matches.join('; ') }, + # locations: [{ + # physicalLocation: { + # artifactLocation: { uri: file.filePath.replace(/^\.\//,''), uriBaseId: '%SRCROOT%' }, + # region: { startLine: 1 }, + # }, + # }], + # })) + # ), + # }], + # }; + + # fs.writeFileSync('scan-results.sarif', JSON.stringify(sarif, null, 2)); + # console.log('SARIF written.'); + # SCRIPT + + # - name: Upload SARIF to GitHub Security tab + # if: always() + # uses: github/codeql-action/upload-sarif@v3 + # with: + # sarif_file: scan-results.sarif + # category: polinrider-malware-scan + + # ── 6. Block deployment if infected ────────────────────────────────────── + - name: Enforce clean-scan gate + if: steps.scanner.outputs.SCANNER_EXIT == '1' || steps.git-check.outputs.ARTIFACTS_FOUND != '0' + shell: bash + run: | + echo "" + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ DEPLOYMENT BLOCKED — PolinRider malware signatures detected ║" + echo "║ ║" + echo "║ This repository contains code signatures consistent with the ║" + echo "║ PolinRider supply-chain campaign (DPRK / Famous Chollima). ║" + echo "║ ║" + echo "║ DO NOT run npm install, build, or deploy until remediated. ║" + echo "║ ║" + echo "║ See scan-report.json artifact for full details. ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + exit 1 + + # ── Dependent jobs — add `needs: polinrider-scan` to block on clean scan ───── + # Example: your existing build/deploy jobs should look like this: + # + # build: + # needs: polinrider-scan + # runs-on: ubuntu-latest + # steps: + # ... + # + # deploy: + # needs: [polinrider-scan, build] + # ... diff --git a/.gitignore b/.gitignore index 9f6cafc6d..bb9b43556 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,6 @@ coverage/ .idea/ .vscode/ .npmrc -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat # emacs cache files *~ diff --git a/CLAUDE.md b/CLAUDE.md index c06000dac..d67e6c3d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre | `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | | `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | | `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | +| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | | `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | | `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | @@ -73,6 +74,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are - `edr-freight-web/portal`: 5173 - `edr-freight-web/backoffice`: 5183 - `edr-passenger-api`: 3002 +- `edr-payment-api`: 3003 - `edr-passenger-web/portal`: 5174 - `edr-passenger-web/backoffice`: 5184 @@ -80,6 +82,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are - `postgres-freight` (port 5433): database `edr_freight` — freight API only. - `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. +- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`). - Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues. ## Adding a new module to a NestJS app diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index aaf37c3e4..5ea89f4e5 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -65,13 +65,25 @@ CARD_WEBHOOK_SECRET= CARD_WEBHOOK_URL= CARD_RETURN_URL= -# Waafi (Djibouti Mobile Money) -WAAFI_BASE_URL=https://api.waafipay.net +# Waafi (Djibouti Mobile Money — Hosted Payment Page) +# Sandbox: https://sandbox.waafipay.net | Production: https://api.waafipay.net +WAAFI_BASE_URL=https://sandbox.waafipay.net WAAFI_MERCHANT_UID= -WAAFI_API_USER_ID= -WAAFI_API_KEY= +WAAFI_STORE_ID= +WAAFI_HPP_KEY= +# HMAC secret returned once by WEBHOOK_REGISTER — verifies inbound webhooks +WAAFI_WEBHOOK_SECRET= +WAAFI_PAYMENT_METHOD=MWALLET_ACCOUNT +# Waafi has no ETB; overrides booking currency (USD/DJF/SLSH) +WAAFI_CURRENCY=DJF +WAAFI_HPP_SUCCESS_URL= +WAAFI_HPP_FAILURE_URL= +# 1 = POST, 2 = GET, 4 = Result Token +WAAFI_HPP_RESP_FORMAT=1 +# Registered webhook URL (registration done out-of-band) WAAFI_NOTIFY_URL= -WAAFI_RETURN_URL= +# DEV ONLY — disable TLS cert verification (sandbox serves a *.waafi.com cert). Never true in prod. +WAAFI_INSECURE_TLS=false # Payment Configuration PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index a0d6c988b..0e02202df 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -21,7 +21,6 @@ }, "dependencies": { - "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", @@ -47,7 +46,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", - "tsconfig-paths": "^4.2.0" + "tsconfig-paths": "^4.2.0", + "uuid": "^10.0.0" }, "devDependencies": { "@edr/eslint-config": "workspace:*", @@ -67,7 +67,8 @@ "supertest": "^7.0.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "@types/uuid": "^9.0.0" }, "prisma": { "schema": "prisma/schema.prisma" diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 71f822c7d..17283e15b 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -1,4 +1,4 @@ -import { Prisma, PrismaClient } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { randomUUID as uuidv4 } from 'crypto'; @@ -224,12 +224,10 @@ async function seedCoaches() { create: coach, }); - // Rebuild the coach's seats from scratch. A plain upsert keyed on - // coachId_seatNumber can't reconcile a changed layout (it's blind to the - // @@unique([coachId, row, col]) constraint), so stale row/col data collides. - await prisma.seat.deleteMany({ where: { coachId: c.id } }); - - const seats: Prisma.SeatCreateManyInput[] = []; + // Idempotently reconcile the coach's seats. Upsert keyed on the + // @@unique([coachId, row, col]) constraint so a re-seed updates existing + // rows in place instead of deleting them. Deleting Seats fails with a P2003 + // FK violation once BookingSeat/SeatBlock/TicketSeat rows reference them. let seatIndex = 1; for (let row = 1; row <= Math.ceil(coach.capacity / 2); row++) { for (const col of ['A', 'B', 'C', 'D']) { @@ -241,19 +239,21 @@ async function seedCoaches() { else bedPosition = 'lower'; } - seats.push({ - coachId: c.id, + const seatData = { seatNumber: seatIndex.toString(), - row, - col, isWindow: col === 'A' || col === 'D', isAisle: col === 'B' || col === 'C', bedPosition, + }; + + await prisma.seat.upsert({ + where: { coachId_row_col: { coachId: c.id, row, col } }, + update: seatData, + create: { coachId: c.id, row, col, ...seatData }, }); seatIndex++; } } - await prisma.seat.createMany({ data: seats }); totalSeats += coach.capacity; } console.log(` ✅ ${coaches.length} coaches with ${totalSeats} seats created`); @@ -552,25 +552,49 @@ async function seedFraudRules() { console.log(` ✅ ${rules.length} fraud detection rules created`); } +// Run a seed step in isolation: if it throws (FK conflict, duplicate row, +// missing record, etc.) log the error and keep going so the rest of the seed — +// and the API startup that follows it — are never blocked by one bad step. +async function runStep(name: string, step: () => Promise): Promise { + try { + await step(); + return true; + } catch (e) { + console.error(`⚠️ Seed step "${name}" failed — skipping and continuing:`, e); + return false; + } +} + async function main() { console.log('🌱 Comprehensive EDR Seed Starting...\n'); - await seedSystemUsers(); - await seedStations(); - await seedCoachTypesAndClasses(); - await seedRoute(); - await seedCoaches(); - await seedTrips(); - await seedFareRules(); - await seedCurrency(); - await seedPaymentMethods(); - await seedNotificationTemplates(); - await seedMenuAndFood(); - await seedPromotions(); - await seedFAQ(); - await seedFraudRules(); + const steps: Array<[string, () => Promise]> = [ + ['system users', seedSystemUsers], + ['stations', seedStations], + ['coach types & classes', seedCoachTypesAndClasses], + ['route', seedRoute], + ['coaches', seedCoaches], + ['trips', seedTrips], + ['fare rules', seedFareRules], + ['currency', seedCurrency], + ['payment methods', seedPaymentMethods], + ['notification templates', seedNotificationTemplates], + ['menu & food', seedMenuAndFood], + ['promotions', seedPromotions], + ['FAQ', seedFAQ], + ['fraud rules', seedFraudRules], + ]; - console.log('\n✅ Seed complete!\n'); + let failed = 0; + for (const [name, step] of steps) { + if (!(await runStep(name, step))) failed++; + } + + if (failed > 0) { + console.warn(`\n⚠️ Seed finished with ${failed}/${steps.length} step(s) failed (see logs above).\n`); + } else { + console.log('\n✅ Seed complete!\n'); + } console.log('🔑 System Users:'); console.log(' Admin: admin@edr-platform.com / admin123'); console.log(' Passenger: kelemu@email.com / password123'); @@ -581,8 +605,10 @@ async function main() { main() .catch((e) => { - console.error('❌ Seed failed:', e); - process.exit(1); + // Intentionally do NOT process.exit(1): the docker entrypoint runs under + // `set -e`, so a non-zero exit here would abort container startup and the + // API would never boot. Log and exit cleanly instead. + console.error('❌ Seed crashed unexpectedly — continuing so the API can start:', e); }) .finally(async () => { await prisma.$disconnect(); diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 16178a1b4..0109cfc39 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { PrismaModule } from './common/prisma.module'; +import { AuditModule } from './common/audit.module'; import { I18nModule } from './common/i18n/i18n.module'; import { IamModule } from './common/iam.module'; import { LocaleMiddleware } from './common/i18n/locale.middleware'; @@ -38,6 +39,7 @@ import { FraudModule } from './modules/fraud/fraud.module'; import { SeatClassesModule } from './modules/seat-classes/seat-classes.module'; import { FareEngineModule } from './modules/fare-engine/fare-engine.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { AuditModuleFeature } from './modules/audit/audit.module'; @Module({ imports: [ @@ -57,6 +59,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module'; ScheduleModule.forRoot(), EventEmitterModule.forRoot(), PrismaModule, + AuditModule, I18nModule, IamModule, AuthModule, @@ -83,6 +86,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module'; SeatClassesModule, FareEngineModule, VerifaydaModule, + AuditModuleFeature, ], }) export class AppModule implements NestModule { diff --git a/apps/edr-passenger-api/src/common/audit.module.ts b/apps/edr-passenger-api/src/common/audit.module.ts new file mode 100644 index 000000000..a4ba9262f --- /dev/null +++ b/apps/edr-passenger-api/src/common/audit.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from './prisma.module'; +import { AuditService } from './audit.service'; + +@Module({ + imports: [PrismaModule], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/apps/edr-passenger-api/src/common/audit.service.ts b/apps/edr-passenger-api/src/common/audit.service.ts new file mode 100644 index 000000000..342e786bd --- /dev/null +++ b/apps/edr-passenger-api/src/common/audit.service.ts @@ -0,0 +1,92 @@ +import { Injectable, Inject, Optional } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { PrismaService } from './prisma.service'; + +@Injectable() +export class AuditService { + constructor( + private prisma: PrismaService, + @Optional() @Inject(REQUEST) private request?: any, + ) {} + + async log(input: { + userId?: string; + action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string; + entityType: string; + entityId?: string; + oldData?: any; + newData?: any; + }) { + try { + const ipAddress = this.getIpAddress(); + const userAgent = this.getUserAgent(); + + await this.prisma.auditLog.create({ + data: { + userId: input.userId, + action: input.action, + entityType: input.entityType, + entityId: input.entityId, + oldData: input.oldData, + newData: input.newData, + ipAddress, + userAgent, + }, + }); + } catch (error) { + console.error('Failed to log audit event:', error); + // Don't throw - audit logging should not break main operations + } + } + + private getIpAddress(): string { + if (!this.request) return ''; + + return ( + this.request.headers['x-forwarded-for']?.split(',')[0].trim() || + this.request.headers['x-real-ip'] || + this.request.connection?.remoteAddress || + this.request.socket?.remoteAddress || + this.request.ip || + '' + ); + } + + private getUserAgent(): string { + return this.request?.headers?.['user-agent'] || ''; + } + + async getLogs(filters: any = {}) { + const where: any = {}; + + if (filters.search) { + where.OR = [ + { entityId: { contains: filters.search, mode: 'insensitive' } }, + { user: { email: { contains: filters.search, mode: 'insensitive' } } }, + { user: { fullName: { contains: filters.search, mode: 'insensitive' } } }, + ]; + } + + if (filters.action) { + where.action = filters.action; + } + + if (filters.entityType) { + where.entityType = filters.entityType; + } + + return this.prisma.auditLog.findMany({ + where, + include: { user: true }, + orderBy: { createdAt: 'desc' }, + take: 500, // Limit to last 500 logs + }); + } + + async getLog(id: string) { + return this.prisma.auditLog.findUnique({ + where: { id }, + include: { user: true }, + }); + } +} diff --git a/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts b/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts new file mode 100644 index 000000000..b47876d4c --- /dev/null +++ b/apps/edr-passenger-api/src/common/guards/service-auth.guard.ts @@ -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(); + 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; + } +} diff --git a/apps/edr-passenger-api/src/config/waafi.config.ts b/apps/edr-passenger-api/src/config/waafi.config.ts index c3afab7ce..639e66e2b 100644 --- a/apps/edr-passenger-api/src/config/waafi.config.ts +++ b/apps/edr-passenger-api/src/config/waafi.config.ts @@ -1,10 +1,27 @@ import { registerAs } from '@nestjs/config'; export default registerAs('waafi', () => ({ - baseUrl: process.env.WAAFI_BASE_URL ?? 'https://api.waafipay.net', + // `/asm` is appended in the provider; use sandbox by default, switch to + // https://api.waafipay.net in production. + baseUrl: process.env.WAAFI_BASE_URL ?? 'https://sandbox.waafipay.net', + // HPP credentials (Hosted Payment Page family). merchantUid: process.env.WAAFI_MERCHANT_UID ?? '', - apiUserId: process.env.WAAFI_API_USER_ID ?? '', - apiKey: process.env.WAAFI_API_KEY ?? '', + storeId: process.env.WAAFI_STORE_ID ?? '', + hppKey: process.env.WAAFI_HPP_KEY ?? '', + // HMAC secret returned once by WEBHOOK_REGISTER; verifies inbound webhooks. + webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? '', + // Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front. + paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? 'MWALLET_ACCOUNT', + // Waafi has no ETB; when set this overrides the booking currency (USD/DJF/SLSH). + currency: process.env.WAAFI_CURRENCY ?? 'DJF', + // Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth). + successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? '', + failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? '', + // Callback data format: 1 = POST, 2 = GET, 4 = Result Token. + respDataFormat: Number(process.env.WAAFI_HPP_RESP_FORMAT ?? '1'), + // Registered webhook URL (reference only; registration is performed out-of-band). notifyUrl: process.env.WAAFI_NOTIFY_URL ?? '', - returnUrl: process.env.WAAFI_RETURN_URL ?? '', + // DEV ONLY: disable TLS cert verification. The Waafi sandbox serves a *.waafi.com cert that + // does not match sandbox.waafipay.net (ERR_TLS_CERT_ALTNAME_INVALID). Never enable in prod. + insecureTls: process.env.WAAFI_INSECURE_TLS === 'true', })); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index fe853ef76..a7bd85f81 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -8,7 +8,9 @@ import { ResponseTransformInterceptor } from "./common/interceptors/response-tra import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor"; async function bootstrap() { - const app = await NestFactory.create(AppModule); + // rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers + // (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed. + const app = await NestFactory.create(AppModule, { rawBody: true }); app.enableCors({ origin: [ diff --git a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts new file mode 100644 index 000000000..37bc89855 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { AuditService } from '../../common/audit.service'; +import { IamGuard } from '../../common/iam-adapter'; + +@ApiTags('Audit') +@Controller('audit') +@UseGuards(IamGuard) +@ApiBearerAuth('IAM-auth') +export class AuditController { + constructor(private auditService: AuditService) {} + + @Get('logs') + @ApiOperation({ + summary: 'Get audit logs', + description: 'Retrieve system audit logs with optional filtering', + }) + @ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' }) + @ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' }) + @ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' }) + async getLogs( + @Query('search') search?: string, + @Query('action') action?: string, + @Query('entityType') entityType?: string, + ) { + const filters = { + search: search || undefined, + action: action || undefined, + entityType: entityType || undefined, + }; + + const items = await this.auditService.getLogs(filters); + return { items }; + } + + @Get('logs/:id') + @ApiOperation({ summary: 'Get audit log by ID' }) + async getLog(@Param('id') id: string) { + return this.auditService.getLog(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.module.ts b/apps/edr-passenger-api/src/modules/audit/audit.module.ts new file mode 100644 index 000000000..8b161d55c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/audit/audit.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { AuditModule } from '../../common/audit.module'; +import { AuditController } from './audit.controller'; + +@Module({ + imports: [AuditModule, HttpModule], + controllers: [AuditController], +}) +export class AuditModuleFeature {} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index f9a3e0ea4..a588e7330 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { AuditModule } from '../../common/audit.module'; import { BookingsController } from './bookings.controller'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; @@ -8,7 +9,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; @Module({ - imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule], + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts new file mode 100644 index 000000000..98262c4c3 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.controller.ts @@ -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 { + return this.paymentsService.handlePaymentEvent(event); + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts new file mode 100644 index 000000000..7f732ae51 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts new file mode 100644 index 000000000..7b1789ae9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -0,0 +1,95 @@ +import { BadGatewayException, Injectable, Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { AxiosError } from "axios"; +import { firstValueFrom } from "rxjs"; +import { + InitiatePaymentRequest, + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService, +} from "@edr/types"; + +/** + * Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's + * side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here; + * provider calls, intents, and webhooks live in the payment service. + */ +@Injectable() +export class PaymentClientService { + private readonly logger = new Logger(PaymentClientService.name); + private readonly baseUrl = ( + process.env.PAYMENT_API_URL ?? "http://localhost:3003" + ).replace(/\/$/, ""); + private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; + + constructor(private readonly http: HttpService) {} + + /** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */ + async initiate( + request: InitiatePaymentRequest, + ): Promise { + 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 { + 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( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + this.logger.log("====================================================================="); + this.logger.log(`URL ${url}`); + this.logger.log("====================================================================="); + try { + const response = await firstValueFrom( + this.http.request({ + 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"); + } + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts index b686269c5..0397e62ab 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.adapters.ts @@ -1,10 +1,50 @@ -export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; } - -export async function telebirrAdapter(_a: number, ref: string): Promise { - await new Promise((r) => setTimeout(r, 200)); - return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } }; +export interface GatewayResult { + success: boolean; + providerRef: string; + clientAction?: { type: string; url?: string }; +} + +export async function telebirrAdapter( + _a: number, + ref: string, +): Promise { + 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 { + await new Promise((r) => setTimeout(r, 150)); + return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; +} +export async function eBirrAdapter( + _a: number, + ref: string, +): Promise { + await new Promise((r) => setTimeout(r, 150)); + return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; +} +export async function cardAdapter( + _a: number, + ref: string, +): Promise { + 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 { + return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; } -export async function cbeBirrAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; } -export async function eBirrAdapter(_a: number, ref: string): Promise { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; } -export async function cardAdapter(_a: number, ref: string): Promise { 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 { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 93fd901df..373e10513 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,34 +1,59 @@ -import { Body, Controller, Get, HttpStatus, Param, Post, Query, Res, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiOkResponse, ApiProduces } from '@nestjs/swagger'; -import { Response } from 'express'; -import { PaymentsService } from './payments.service'; -import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, PaymentRegionEnum, SupportedPaymentMethodDto, PaymentMethodTypeEnum, PaymentPlatformDto } from './payments.dto'; -import { JwtGuard } from '../../common/jwt.guard'; -import { RolesGuard } from '../../common/roles.guard'; -import { Roles } from '../../common/roles.decorator'; -import { UserRole } from '@prisma/client'; +import { + Body, + Controller, + Get, + HttpStatus, + Param, + Post, + Query, + Res, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiQuery, + ApiOkResponse, + ApiProduces, +} from "@nestjs/swagger"; +import { Response } from "express"; +import { PaymentsService } from "./payments.service"; +import { + InitiatePaymentDto, + RefundDto, + AddPaymentMethodDto, + PaymentRegionEnum, + SupportedPaymentMethodDto, + PaymentMethodTypeEnum, + PaymentPlatformDto, +} from "./payments.dto"; +import { JwtGuard } from "../../common/jwt.guard"; +import { RolesGuard } from "../../common/roles.guard"; +import { Roles } from "../../common/roles.decorator"; +import { UserRole } from "@prisma/client"; -@ApiTags('Payment') -@Controller('payments') +@ApiTags("Payment") +@Controller("payments") export class PaymentsController { constructor(private service: PaymentsService) {} - @Get('all') + @Get("all") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.SUPERVISOR, UserRole.STAFF) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get all payments with filters (staff/admin only)' }) - @ApiQuery({ name: 'search', required: false }) - @ApiQuery({ name: 'status', required: false }) - @ApiQuery({ name: 'method', required: false }) - @ApiQuery({ name: 'page', required: false }) - @ApiQuery({ name: 'pageSize', required: false }) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) + @ApiQuery({ name: "search", required: false }) + @ApiQuery({ name: "status", required: false }) + @ApiQuery({ name: "method", required: false }) + @ApiQuery({ name: "page", required: false }) + @ApiQuery({ name: "pageSize", required: false }) async getAll( - @Query('search') search?: string, - @Query('status') status?: string, - @Query('method') method?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, + @Query("search") search?: string, + @Query("status") status?: string, + @Query("method") method?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, ) { return this.service.getAll({ search, @@ -38,80 +63,121 @@ export class PaymentsController { pageSize: pageSize ? parseInt(pageSize) : 10, }); } - - @Post('initiate') - @ApiOperation({ - summary: 'Initiate payment with nationality-based payment methods', - description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion` + + @Post("initiate") + @ApiOperation({ + summary: "Initiate payment with nationality-based payment methods", + description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`, }) - initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); } - - @Get('intents/:bookingId') - @ApiOperation({ summary: 'Get payment intent status for a booking' }) - getIntent(@Param('bookingId') bookingId: string) { return this.service.getIntentByBookingId(bookingId); } - - @Post('refund') + initiatePayment(@Body() dto: InitiatePaymentDto) { + return this.service.initiatePayment(dto); + } + + @Get("intents/:bookingId") + @ApiOperation({ summary: "Get payment intent status for a booking" }) + getIntent(@Param("bookingId") bookingId: string) { + return this.service.getIntentByBookingId(bookingId); + } + + @Post("refund") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.STAFF, UserRole.AGENT) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Refund a confirmed booking (staff/agent only)' }) - refund(@Body() dto: RefundDto) { return this.service.refund(dto); } + @ApiBearerAuth("JWT-auth") + @ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" }) + refund(@Body() dto: RefundDto) { + return this.service.refund(dto); + } - @Post('methods') + @Post("methods") @UseGuards(JwtGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.STAFF) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Add a payment system to the platform catalog (admin only)' }) - addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); } - - @Get('methods') + @ApiBearerAuth("JWT-auth") @ApiOperation({ - summary: 'List payment systems supported by the platform', - description: 'Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger\'s nationality.', + summary: "Add a payment system to the platform catalog (admin only)", }) - @ApiQuery({ name: 'region', enum: PaymentRegionEnum, required: false }) - @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) - getMethods(@Query('region') region?: PaymentRegionEnum) { return this.service.getSupportedPaymentMethods(region); } + addMethod(@Body() dto: AddPaymentMethodDto) { + return this.service.addPaymentMethod(dto); + } - @Get('checkout') + @Get("methods") @ApiOperation({ - summary: 'Browser checkout redirect', - description: 'Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.', + summary: "List payment systems supported by the platform", + description: + "Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.", }) - @ApiQuery({ name: 'bookingId', required: true }) - @ApiQuery({ name: 'method', enum: PaymentMethodTypeEnum, required: true }) - @ApiQuery({ name: 'platform', enum: ['web', 'mobile'], required: false }) - @ApiProduces('text/html') + @ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false }) + @ApiOkResponse({ type: [SupportedPaymentMethodDto] }) + getMethods(@Query("region") region?: PaymentRegionEnum) { + return this.service.getSupportedPaymentMethods(region); + } + + @Get("checkout") + @ApiOperation({ + summary: "Browser checkout redirect", + description: + "Initiates payment and returns an HTML page that auto-redirects the browser to the provider checkout URL. Designed to be opened directly in a browser tab.", + }) + @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiProduces("text/html") async checkout( - @Query('bookingId') bookingId: string, - @Query('method') method: PaymentMethodTypeEnum, - @Query('platform') platform: PaymentPlatformDto = 'web', + @Query("bookingId") bookingId: string, + @Query("method") method: PaymentMethodTypeEnum, + @Query("platform") platform: PaymentPlatformDto = "web", @Res() res: Response, ) { if (!bookingId) { - return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing required query parameter: bookingId')); + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send( + this.buildErrorHtml("Missing required query parameter: bookingId"), + ); } if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { - return res.status(HttpStatus.BAD_REQUEST).type('html').send(this.buildErrorHtml('Missing or invalid query parameter: method')); + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send( + this.buildErrorHtml("Missing or invalid query parameter: method"), + ); } try { - const result = await this.service.initiatePayment({ bookingId, method, platform }); - const url = result.clientAction?.type === 'REDIRECT' ? result.clientAction.url : undefined; + const result = await this.service.initiatePayment({ + bookingId, + method, + platform, + }); + const url = + result.clientAction?.type === "REDIRECT" + ? result.clientAction.url + : undefined; if (url) { - return res.status(HttpStatus.OK).type('html').send(this.buildRedirectHtml(url)); + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildRedirectHtml(url)); } - return res.status(HttpStatus.OK).type('html').send(this.buildStatusHtml(result.status, result.intentId)); + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildStatusHtml(result.status, result.intentId)); } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'An unexpected error occurred'; - return res.status(HttpStatus.OK).type('html').send(this.buildErrorHtml(message)); + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildErrorHtml(message)); } } private buildRedirectHtml(url: string): string { - const escaped = url.replace(/\"/g, '"'); + const escaped = url.replace(/\"/g, """); return ` diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 9d8467c3f..309bd4a0a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -1,36 +1,53 @@ -import { IsString, IsEnum, IsOptional, IsIn, IsBoolean, IsInt } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { PaymentIntentStatus } from '@prisma/client'; +import { + IsString, + IsEnum, + IsOptional, + IsIn, + IsBoolean, + IsInt, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { PaymentIntentStatus } from "@prisma/client"; export enum PaymentRegionEnum { - ETHIOPIA = 'ETHIOPIA', - DJIBOUTI = 'DJIBOUTI', - INTERNATIONAL = 'INTERNATIONAL', - GLOBAL = 'GLOBAL', + ETHIOPIA = "ETHIOPIA", + DJIBOUTI = "DJIBOUTI", + INTERNATIONAL = "INTERNATIONAL", + GLOBAL = "GLOBAL", } -export enum PaymentMethodTypeEnum { - TELEBIRR = 'TELEBIRR', // Ethiopia - CBE_BIRR = 'CBE_BIRR', // Ethiopia - EBIRR = 'EBIRR', // Ethiopia - WAAFI = 'WAAFI', // Djibouti - CARD = 'CARD', // International - WALLET = 'WALLET' // Internal +export enum PaymentMethodTypeEnum { + TELEBIRR = "TELEBIRR", // Ethiopia + CBE_BIRR = "CBE_BIRR", // Ethiopia + EBIRR = "EBIRR", // Ethiopia + WAAFI = "WAAFI", // Djibouti + CARD = "CARD", // International + WALLET = "WALLET", // Internal } -export type PaymentPlatformDto = 'web' | 'mobile'; +export type PaymentPlatformDto = "web" | "mobile"; export class InitiatePaymentDto { - @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; + @ApiProperty({ example: "booking-uuid" }) @IsString() bookingId: string; @ApiProperty({ enum: PaymentMethodTypeEnum, - description: 'Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)', - example: 'TELEBIRR' - }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum; - @ApiPropertyOptional({ description: 'Saved payment method ID (optional)' }) @IsOptional() @IsString() paymentMethodId?: string; - @ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web', description: 'Payment platform (web or mobile)' }) + description: + "Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), WALLET (Internal)", + example: "TELEBIRR", + }) + @IsEnum(PaymentMethodTypeEnum) + method: PaymentMethodTypeEnum; + @ApiPropertyOptional({ description: "Saved payment method ID (optional)" }) @IsOptional() - @IsIn(['web', 'mobile']) + @IsString() + paymentMethodId?: string; + @ApiPropertyOptional({ + enum: ["web", "mobile"], + default: "web", + description: "Payment platform (web or mobile)", + }) + @IsOptional() + @IsIn(["web", "mobile"]) platform?: PaymentPlatformDto; } @@ -40,42 +57,76 @@ export class RefundDto { } export class AddPaymentMethodDto { - @ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum; + @ApiProperty({ enum: PaymentMethodTypeEnum }) + @IsEnum(PaymentMethodTypeEnum) + type: PaymentMethodTypeEnum; @ApiProperty() @IsString() displayName: string; - @ApiProperty({ enum: PaymentRegionEnum }) @IsEnum(PaymentRegionEnum) region: PaymentRegionEnum; - @ApiPropertyOptional({ example: 'ETB' }) @IsOptional() @IsString() currency?: string; + @ApiProperty({ enum: PaymentRegionEnum }) + @IsEnum(PaymentRegionEnum) + region: PaymentRegionEnum; + @ApiPropertyOptional({ example: "ETB" }) + @IsOptional() + @IsString() + currency?: string; @ApiPropertyOptional() @IsOptional() @IsString() providerId?: string; - @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() enabled?: boolean; - @ApiPropertyOptional({ default: 0 }) @IsOptional() @IsInt() sortOrder?: number; + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + enabled?: boolean; + @ApiPropertyOptional({ default: 0 }) + @IsOptional() + @IsInt() + sortOrder?: number; } export class SupportedPaymentMethodDto { @ApiProperty({ enum: PaymentMethodTypeEnum }) type: PaymentMethodTypeEnum; - @ApiProperty({ example: 'Telebirr' }) displayName: string; + @ApiProperty({ example: "Telebirr" }) displayName: string; @ApiProperty({ enum: PaymentRegionEnum }) region: PaymentRegionEnum; - @ApiProperty({ example: 'ETB', description: 'Settlement currency for this method' }) currency: string; - @ApiProperty({ description: 'Whether the platform currently accepts this method' }) enabled: boolean; + @ApiProperty({ + example: "ETB", + description: "Settlement currency for this method", + }) + currency: string; + @ApiProperty({ + description: "Whether the platform currently accepts this method", + }) + enabled: boolean; } export class ClientActionDto { - @ApiProperty({ enum: ['REDIRECT', 'LAUNCH_APP'] }) type: 'REDIRECT' | 'LAUNCH_APP'; - @ApiPropertyOptional({ description: 'Set when type=REDIRECT (web flow)' }) url?: string; - @ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) prepayId?: string; - @ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) receiveCode?: string; - @ApiPropertyOptional({ description: 'Set when type=LAUNCH_APP (mobile flow)' }) shortCode?: string; + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) type: + | "REDIRECT" + | "LAUNCH_APP"; + @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) + url?: string; + @ApiPropertyOptional({ + description: "Set when type=LAUNCH_APP (mobile flow)", + }) + prepayId?: string; + @ApiPropertyOptional({ + description: "Set when type=LAUNCH_APP (mobile flow)", + }) + receiveCode?: string; + @ApiPropertyOptional({ + description: "Set when type=LAUNCH_APP (mobile flow)", + }) + shortCode?: string; } export class InitiateResponseDto { @ApiProperty() intentId: string; @ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus; - @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; + @ApiPropertyOptional({ type: ClientActionDto }) + clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; } export class IntentStatusDto { @ApiProperty() intentId: string; @ApiProperty({ enum: PaymentIntentStatus }) status: PaymentIntentStatus; - @ApiPropertyOptional({ type: ClientActionDto }) clientAction?: ClientActionDto; + @ApiPropertyOptional({ type: ClientActionDto }) + clientAction?: ClientActionDto; @ApiPropertyOptional() merchantOrderId?: string; @ApiPropertyOptional() paidAt?: string; @ApiPropertyOptional() failureCode?: string; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts index 0fe3bdd3b..0fd594b39 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts @@ -1,10 +1,10 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import request from 'supertest'; -import { AppModule } from '../../app.module'; -import { PrismaService } from '../../common/prisma.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import request from "supertest"; +import { AppModule } from "../../app.module"; +import { PrismaService } from "../../common/prisma.service"; -describe('Payments E2E', () => { +describe("Payments E2E", () => { let app: INestApplication; let prisma: PrismaService; let authToken: string; @@ -16,47 +16,123 @@ describe('Payments E2E', () => { }).compile(); app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true }), + ); await app.init(); prisma = app.get(PrismaService); const testUser = await prisma.user.create({ - data: { email: 'payment-test@example.com', phone: '+251911111112', fullName: 'Payment Test User', passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', role: 'PASSENGER' }, + data: { + email: "payment-test@example.com", + phone: "+251911111112", + fullName: "Payment Test User", + passwordHash: "$2b$10$abcdefghijklmnopqrstuvwxyz", + role: "PASSENGER", + }, }); - const passenger = await prisma.passenger.create({ data: { userId: testUser.id } }); + const passenger = await prisma.passenger.create({ + data: { userId: testUser.id }, + }); - await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000, currency: 'ETB' } }); + await prisma.walletAccount.create({ + data: { + passengerId: passenger.id, + balanceMinor: 100000, + currency: "ETB", + }, + }); - authToken = 'mock-jwt-token'; + authToken = "mock-jwt-token"; - const station1 = await prisma.station.create({ data: { code: 'TST1', name: 'Test Station 1', city: 'Test City', lat: 9.0, lng: 38.0 } }); - const station2 = await prisma.station.create({ data: { code: 'TST2', name: 'Test Station 2', city: 'Test City 2', lat: 9.5, lng: 38.5 } }); + const station1 = await prisma.station.create({ + data: { + code: "TST1", + name: "Test Station 1", + city: "Test City", + lat: 9.0, + lng: 38.0, + }, + }); + const station2 = await prisma.station.create({ + data: { + code: "TST2", + name: "Test Station 2", + city: "Test City 2", + lat: 9.5, + lng: 38.5, + }, + }); - const train = await prisma.train.create({ data: { number: 'TEST-001', name: 'Test Train' } }); + const train = await prisma.train.create({ + data: { number: "TEST-001", name: "Test Train" }, + }); const schedule = await prisma.trainSchedule.create({ - data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 }, + data: { + trainId: train.id, + originStationId: station1.id, + destinationStationId: station2.id, + departureAt: new Date(Date.now() + 86400000), + arrivalAt: new Date(Date.now() + 90000000), + durationMinutes: 60, + }, }); - const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } }); + const coachType = await prisma.coachType.create({ + data: { name: "Standard", code: "STD" }, + }); const seatClass = await prisma.seatClass.create({ - data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id }, + data: { + name: "Economy Regular", + description: "Standard economy seating", + baseFareMinor: 45000, + isActive: true, + coachTypeId: coachType.id, + }, }); const coach = await prisma.coach.create({ - data: { coachTypeId: coachType.id, number: 'TEST-C1', arrangement: '2+2', capacity: 10, status: 'ACTIVE' }, + data: { + coachTypeId: coachType.id, + number: "TEST-C1", + arrangement: "2+2", + capacity: 10, + status: "ACTIVE", + }, }); - const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } }); + const seat = await prisma.seat.create({ + data: { + coachId: coach.id, + row: 1, + col: "A", + seatNumber: "1A", + status: "AVAILABLE", + }, + }); const booking = await prisma.booking.create({ - data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' }, + data: { + bookingRef: "TEST-BOOK-001", + passengerId: passenger.id, + scheduleId: schedule.id, + status: "PENDING_PAYMENT", + totalMinor: 50000, + currency: "ETB", + }, }); - await prisma.bookingSeat.create({ data: { bookingId: booking.id, seatId: seat.id, passengerName: 'Test Passenger' } }); + await prisma.bookingSeat.create({ + data: { + bookingId: booking.id, + seatId: seat.id, + passengerName: "Test Passenger", + }, + }); bookingId = booking.id; }); @@ -71,89 +147,60 @@ describe('Payments E2E', () => { prisma.coach.deleteMany(), prisma.trainSchedule.deleteMany(), prisma.train.deleteMany(), - prisma.station.deleteMany({ where: { code: { in: ['TST1', 'TST2'] } } }), + prisma.station.deleteMany({ where: { code: { in: ["TST1", "TST2"] } } }), prisma.walletLedgerEntry.deleteMany(), prisma.walletAccount.deleteMany(), prisma.passenger.deleteMany(), - prisma.user.deleteMany({ where: { email: 'payment-test@example.com' } }), + prisma.user.deleteMany({ where: { email: "payment-test@example.com" } }), ]); await app.close(); }); - describe('POST /payments/initiate', () => { - it('should initiate wallet payment successfully', async () => { + describe("POST /payments/initiate", () => { + it("should initiate wallet payment successfully", async () => { const response = await request(app.getHttpServer()) - .post('/payments/initiate') - .set('Authorization', `Bearer ${authToken}`) - .send({ bookingId, method: 'WALLET' }) + .post("/payments/initiate") + .set("Authorization", `Bearer ${authToken}`) + .send({ bookingId, method: "WALLET" }) .expect(201); expect(response.body.intentId).toBeDefined(); - expect(response.body.status).toBe('SUCCEEDED'); + expect(response.body.status).toBe("SUCCEEDED"); }); - it('should return 400 for invalid payment method', async () => { + it("should return 400 for invalid payment method", async () => { await request(app.getHttpServer()) - .post('/payments/initiate') - .set('Authorization', `Bearer ${authToken}`) - .send({ bookingId, method: 'INVALID_METHOD' }) + .post("/payments/initiate") + .set("Authorization", `Bearer ${authToken}`) + .send({ bookingId, method: "INVALID_METHOD" }) .expect(400); }); - it('should return 404 for non-existent booking', async () => { + it("should return 404 for non-existent booking", async () => { await request(app.getHttpServer()) - .post('/payments/initiate') - .set('Authorization', `Bearer ${authToken}`) - .send({ bookingId: 'non-existent-id', method: 'WALLET' }) + .post("/payments/initiate") + .set("Authorization", `Bearer ${authToken}`) + .send({ bookingId: "non-existent-id", method: "WALLET" }) .expect(404); }); }); - describe('GET /payments/intents/:bookingId', () => { - it('should get payment intent status', async () => { + describe("GET /payments/intents/:bookingId", () => { + it("should get payment intent status", async () => { const response = await request(app.getHttpServer()) .get(`/payments/intents/${bookingId}`) - .set('Authorization', `Bearer ${authToken}`) + .set("Authorization", `Bearer ${authToken}`) .expect(200); expect(response.body.intentId).toBeDefined(); expect(response.body.status).toBeDefined(); }); - it('should return 404 for non-existent intent', async () => { + it("should return 404 for non-existent intent", async () => { await request(app.getHttpServer()) - .get('/payments/intents/non-existent-booking') - .set('Authorization', `Bearer ${authToken}`) + .get("/payments/intents/non-existent-booking") + .set("Authorization", `Bearer ${authToken}`) .expect(404); }); }); - describe('Webhook endpoints', () => { - it('should handle Telebirr webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/telebirr') - .send({ merch_order_id: 'TEST-ORDER-123', payment_order_id: 'PAY-123', trade_status: 'Completed', sign: 'mock-signature' }) - .expect(200); - }); - - it('should handle CBE Birr webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/cbe-birr') - .send({ merchantId: 'TEST-MERCHANT', merchantOrderId: 'TEST-ORDER-123', orderId: 'CBE-ORDER-123', status: 'SUCCESS', signature: 'mock-signature' }) - .expect(200); - }); - - it('should handle eBirr webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/ebirr') - .send({ merchantCode: 'TEST-MERCHANT', orderNo: 'TEST-ORDER-123', tradeStatus: 'TRADE_SUCCESS', timestamp: Date.now(), sign: 'mock-signature' }) - .expect(200); - }); - - it('should handle Card webhook', async () => { - await request(app.getHttpServer()) - .post('/payments/webhooks/card') - .set('stripe-signature', 'mock-signature') - .send({ id: 'evt_123', type: 'payment_intent.succeeded', data: { object: { id: 'pi_123', status: 'succeeded', amount: 50000, currency: 'ETB', metadata: { merchantOrderId: 'TEST-ORDER-123', bookingRef: 'TEST-BOOK-001' } } }, created: Math.floor(Date.now() / 1000) }) - .expect(200); - }); - }); + // Provider webhooks moved to the payment microservice (apps/edr-payment-api /webhooks/*). }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 1f8086ac3..dfab3e9f1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -1,38 +1,25 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { PaymentsController } from './payments.controller'; -import { PaymentsService } from './payments.service'; -import { SeatsModule } from '../seats/seats.module'; -import { TicketsModule } from '../tickets/tickets.module'; -import { - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, - WaafiProvider, -} from '@edr/payment-providers'; -import { WebhooksController } from './webhooks/webhooks.controller'; -import { TelebirrWebhookService } from './webhooks/telebirr-webhook.service'; -import { CbeBirrWebhookService } from './webhooks/cbe-birr-webhook.service'; -import { EBirrWebhookService } from './webhooks/ebirr-webhook.service'; -import { CardWebhookService } from './webhooks/card-webhook.service'; -import { WaafiWebhookService } from './webhooks/waafi-webhook.service'; +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { PaymentsController } from "./payments.controller"; +import { PaymentsService } from "./payments.service"; +import { InternalPaymentsController } from "./internal-payments.controller"; +import { PaymentClientService } from "./payment-client.service"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { SeatsModule } from "../seats/seats.module"; +import { TicketsModule } from "../tickets/tickets.module"; +/** + * Post-cutover (docs/payment-service phase 6): provider gateways and webhook handlers live in + * apps/edr-payment-api. This module keeps domain validation, the WALLET flow, the payment + * client, and the idempotent mark-paid consumer. + */ @Module({ - imports: [SeatsModule, TicketsModule, HttpModule.register({ timeout: 10_000 })], - controllers: [PaymentsController, WebhooksController], - providers: [ - PaymentsService, - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, - WaafiProvider, - TelebirrWebhookService, - CbeBirrWebhookService, - EBirrWebhookService, - CardWebhookService, - WaafiWebhookService, + imports: [ + SeatsModule, + TicketsModule, + HttpModule.register({ timeout: 10_000 }), ], + controllers: [PaymentsController, InternalPaymentsController], + providers: [PaymentsService, PaymentClientService, ServiceAuthGuard], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index d4a35e14f..1a2ebdf1f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -1,19 +1,21 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PaymentsService } from './payments.service'; -import { PrismaService } from '../../common/prisma.service'; -import { SeatsService } from '../seats/seats.service'; -import { TicketsService } from '../tickets/tickets.service'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Test, TestingModule } from "@nestjs/testing"; +import { PaymentsService } from "./payments.service"; +import { PaymentClientService } from "./payment-client.service"; +import { PrismaService } from "../../common/prisma.service"; +import { SeatsService } from "../seats/seats.service"; +import { TicketsService } from "../tickets/tickets.service"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { PaymentIntentStatus, PaymentMethodType } from "@prisma/client"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; import { - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, -} from '@edr/payment-providers'; -import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService as PaymentServiceEnum, + ProviderMethod, + ProviderPaymentStatus, +} from "@edr/types"; -describe('PaymentsService', () => { +describe("PaymentsService", () => { let service: PaymentsService; let prisma: PrismaService; let seatsService: SeatsService; @@ -62,29 +64,25 @@ describe('PaymentsService', () => { emit: jest.fn(), }; - const mockTelebirrProvider = { - method: PaymentMethodType.TELEBIRR, + const mockPaymentClient = { initiate: jest.fn(), - queryStatus: jest.fn(), + getIntentByReference: jest.fn(), }; - const mockCbeBirrProvider = { - method: PaymentMethodType.CBE_BIRR, - initiate: jest.fn(), - queryStatus: jest.fn(), - }; - - const mockEBirrProvider = { - method: PaymentMethodType.EBIRR, - initiate: jest.fn(), - queryStatus: jest.fn(), - }; - - const mockCardProvider = { - method: PaymentMethodType.CARD, - initiate: jest.fn(), - queryStatus: jest.fn(), - }; + const requiresActionSnapshot = ( + provider: ProviderMethod, + ): PaymentIntentSnapshot => ({ + intentId: "remote-intent-1", + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + merchantOrderId: "PSG-MERCH-123", + provider, + status: ProviderPaymentStatus.REQUIRES_ACTION, + amountMinor: 50000, + currency: "ETB", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, + }); beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -94,10 +92,7 @@ describe('PaymentsService', () => { { provide: SeatsService, useValue: mockSeatsService }, { provide: TicketsService, useValue: mockTicketsService }, { provide: EventEmitter2, useValue: mockEventEmitter }, - { provide: TelebirrProvider, useValue: mockTelebirrProvider }, - { provide: CbeBirrProvider, useValue: mockCbeBirrProvider }, - { provide: EBirrProvider, useValue: mockEBirrProvider }, - { provide: CardProvider, useValue: mockCardProvider }, + { provide: PaymentClientService, useValue: mockPaymentClient }, ], }).compile(); @@ -108,221 +103,276 @@ describe('PaymentsService', () => { eventEmitter = module.get(EventEmitter2); jest.clearAllMocks(); + mockPaymentClient.getIntentByReference.mockResolvedValue(null); }); - describe('initiatePayment', () => { + describe("initiatePayment", () => { const mockBooking = { - id: 'booking-1', - bookingRef: 'EDR123456', - passengerId: 'passenger-1', + id: "booking-1", + bookingRef: "EDR123456", + passengerId: "passenger-1", totalMinor: 50000, - currency: 'ETB', - status: 'PENDING_PAYMENT', - seats: [{ id: 'seat-1', seatId: 'seat-id-1' }], + currency: "ETB", + status: "PENDING_PAYMENT", + seats: [{ id: "seat-1", seatId: "seat-id-1" }], }; - it('should throw NotFoundException if booking not found', async () => { + it("should throw NotFoundException if booking not found", async () => { mockPrisma.booking.findUnique.mockResolvedValue(null); await expect( service.initiatePayment({ - bookingId: 'invalid', - method: 'TELEBIRR' as any, + bookingId: "invalid", + method: "TELEBIRR" as any, }), ).rejects.toThrow(NotFoundException); }); - it('should throw BadRequestException if booking not payable', async () => { + it("should throw BadRequestException if booking not payable", async () => { mockPrisma.booking.findUnique.mockResolvedValue({ ...mockBooking, - status: 'CONFIRMED', + status: "CONFIRMED", }); await expect( service.initiatePayment({ - bookingId: 'booking-1', - method: 'TELEBIRR' as any, + bookingId: "booking-1", + method: "TELEBIRR" as any, }), ).rejects.toThrow(BadRequestException); }); - it('should initiate Telebirr payment successfully', async () => { + it("should initiate a provider payment through the payment microservice", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); - mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); - mockTelebirrProvider.initiate.mockResolvedValue({ - providerOrderId: 'TB-ORDER-123', - clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' }, - expiresAt: new Date(), - rawInitiation: {}, - }); + mockPaymentClient.initiate.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.TELEBIRR), + ); mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.REQUIRES_ACTION, - merchantOrderId: 'MERCH-123', - clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' }, + merchantOrderId: "PSG-MERCH-123", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, }); const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'TELEBIRR' as any, + bookingId: "booking-1", + method: "TELEBIRR" as any, }); expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); - expect(mockTelebirrProvider.initiate).toHaveBeenCalled(); + expect(result.clientAction?.url).toBe("https://provider.example/pay"); + expect(mockPaymentClient.initiate).toHaveBeenCalledWith( + expect.objectContaining({ + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + orderRef: "EDR123456", + amountMinor: 50000, + currency: "ETB", + provider: "TELEBIRR", + }), + ); + // Snapshot mirrored into the local projection. + expect(mockPrisma.paymentIntent.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { bookingId: "booking-1" } }), + ); }); - it('should initiate CBE Birr payment successfully', async () => { + it("should finalize the booking when the service reports an already-paid intent", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); - mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); - mockCbeBirrProvider.initiate.mockResolvedValue({ - providerOrderId: 'CBE-ORDER-123', - clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' }, - expiresAt: new Date(), - rawInitiation: {}, + mockPaymentClient.initiate.mockResolvedValue({ + ...requiresActionSnapshot(ProviderMethod.WAAFI), + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: "TXN-1", + paidAt: new Date().toISOString(), }); + // Projection clamps SUCCEEDED to PROCESSING; finalizePaymentSuccess flips it. mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', - status: PaymentIntentStatus.REQUIRES_ACTION, - merchantOrderId: 'MERCH-123', - clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' }, + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.PROCESSING, }); - - const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'CBE_BIRR' as any, - }); - - expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); - expect(mockCbeBirrProvider.initiate).toHaveBeenCalled(); - }); - - it('should initiate wallet payment and debit successfully', async () => { - mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); - mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); - mockPrisma.walletAccount.findUnique.mockResolvedValue({ - id: 'wallet-1', - passengerId: 'passenger-1', - balanceMinor: 100000, - }); - mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', + mockPrisma.paymentIntent.findUnique.mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }); mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.SUCCEEDED, - bookingId: 'booking-1', + merchantOrderId: "PSG-MERCH-123", + }); + mockPrisma.loyaltyAccount.findUnique.mockResolvedValue(null); + + const result = await service.initiatePayment({ + bookingId: "booking-1", + method: "WAAFI" as any, + }); + + expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); + expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1"); + }); + + it("should initiate wallet payment and debit successfully", async () => { + mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); + // First call: existing-intent check (none); second call: finalize loads the new intent. + mockPrisma.paymentIntent.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.PROCESSING, + }); + mockPrisma.walletAccount.findUnique.mockResolvedValue({ + id: "wallet-1", + passengerId: "passenger-1", + balanceMinor: 100000, + }); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.PROCESSING, + }); + mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.SUCCEEDED, + bookingId: "booking-1", }); mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({ - id: 'loyalty-1', + id: "loyalty-1", pointsBalance: 100, }); const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'WALLET' as any, + bookingId: "booking-1", + method: "WALLET" as any, }); expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); expect(mockSeatsService.confirmSeats).toHaveBeenCalled(); expect(mockTicketsService.generate).toHaveBeenCalled(); + expect(mockPaymentClient.initiate).not.toHaveBeenCalled(); }); - it('should fail wallet payment with insufficient balance', async () => { + it("should fail wallet payment with insufficient balance", async () => { mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); mockPrisma.walletAccount.findUnique.mockResolvedValue({ - id: 'wallet-1', - passengerId: 'passenger-1', + id: "wallet-1", + passengerId: "passenger-1", balanceMinor: 10000, // Less than booking total }); mockPrisma.paymentIntent.upsert.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.FAILED, - failureCode: 'INSUFFICIENT_BALANCE', + failureCode: "INSUFFICIENT_BALANCE", }); const result = await service.initiatePayment({ - bookingId: 'booking-1', - method: 'WALLET' as any, + bookingId: "booking-1", + method: "WALLET" as any, }); expect(result.status).toBe(PaymentIntentStatus.FAILED); }); }); - describe('finalizePaymentSuccess', () => { - it('should finalize payment and issue ticket', async () => { + describe("finalizePaymentSuccess", () => { + it("should finalize payment and issue ticket", async () => { const mockIntent = { - id: 'intent-1', - bookingId: 'booking-1', + id: "intent-1", + bookingId: "booking-1", status: PaymentIntentStatus.PROCESSING, }; const mockBooking = { - id: 'booking-1', - passengerId: 'passenger-1', + id: "booking-1", + passengerId: "passenger-1", totalMinor: 50000, - seats: [{ seatId: 'seat-1' }], + seats: [{ seatId: "seat-1" }], }; mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent); mockPrisma.booking.findUnique.mockResolvedValue(mockBooking); mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({ - id: 'loyalty-1', + id: "loyalty-1", pointsBalance: 100, }); const result = await service.finalizePaymentSuccess({ - intentId: 'intent-1', - providerTxnId: 'TXN-123', + intentId: "intent-1", + providerTxnId: "TXN-123", }); expect(result.alreadyFinalized).toBe(false); - expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']); - expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1'); - expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', { + expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(["seat-1"]); + expect(mockTicketsService.generate).toHaveBeenCalledWith("booking-1"); + expect(mockEventEmitter.emit).toHaveBeenCalledWith("payment.succeeded", { booking: mockBooking, }); }); - it('should return alreadyFinalized if payment already succeeded', async () => { + it("should return alreadyFinalized if payment already succeeded", async () => { mockPrisma.paymentIntent.findUnique.mockResolvedValue({ - id: 'intent-1', + id: "intent-1", status: PaymentIntentStatus.SUCCEEDED, }); const result = await service.finalizePaymentSuccess({ - intentId: 'intent-1', + intentId: "intent-1", }); expect(result.alreadyFinalized).toBe(true); }); }); - describe('getIntentByBookingId', () => { - it('should return intent status', async () => { + describe("getIntentByBookingId", () => { + it("should return the cached local intent when the payment service has none", async () => { const mockIntent = { - id: 'intent-1', - bookingId: 'booking-1', + id: "intent-1", + bookingId: "booking-1", status: PaymentIntentStatus.SUCCEEDED, method: PaymentMethodType.TELEBIRR, paidAt: new Date(), - merchantOrderId: 'MERCH-123', + merchantOrderId: "MERCH-123", updatedAt: new Date(), }; mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent); + mockPaymentClient.getIntentByReference.mockResolvedValue(null); - const result = await service.getIntentByBookingId('booking-1'); + const result = await service.getIntentByBookingId("booking-1"); - expect(result.intentId).toBe('intent-1'); + expect(result.intentId).toBe("intent-1"); expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED); }); - it('should throw NotFoundException if intent not found', async () => { + it("should mirror a payment-service snapshot into the local projection", async () => { mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); + mockPaymentClient.getIntentByReference.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.WAAFI), + ); + mockPrisma.paymentIntent.upsert.mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, + }); - await expect(service.getIntentByBookingId('invalid')).rejects.toThrow( + const result = await service.getIntentByBookingId("booking-1"); + + expect(mockPaymentClient.getIntentByReference).toHaveBeenCalledWith( + PaymentReferenceType.BOOKING, + "booking-1", + ); + expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION); + expect(result.clientAction?.url).toBe("https://provider.example/pay"); + }); + + it("should throw NotFoundException if intent not found anywhere", async () => { + mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); + mockPaymentClient.getIntentByReference.mockResolvedValue(null); + + await expect(service.getIntentByBookingId("invalid")).rejects.toThrow( NotFoundException, ); }); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 766ae6abe..fe80ae88e 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -1,22 +1,37 @@ -import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; -import { PrismaService } from '../../common/prisma.service'; -import { SeatsService } from '../seats/seats.service'; -import { TicketsService } from '../tickets/tickets.service'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { Prisma, PaymentIntentStatus, PaymentMethodType, PaymentRegion } from '@prisma/client'; -import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto, InitiateResponseDto, IntentStatusDto, PaymentRegionEnum } from './payments.dto'; import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from "@nestjs/common"; +import { PrismaService } from "../../common/prisma.service"; +import { SeatsService } from "../seats/seats.service"; +import { TicketsService } from "../tickets/tickets.service"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { + Prisma, + PaymentIntentStatus, + PaymentMethodType, + PaymentRegion, +} from "@prisma/client"; +import { + InitiatePaymentDto, + RefundDto, + AddPaymentMethodDto, + InitiateResponseDto, + IntentStatusDto, + PaymentRegionEnum, +} from "./payments.dto"; +import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; +import { PaymentClientService } from "./payment-client.service"; +import { + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, ClientAction, - PaymentProvider, - ProviderStatus, ProviderPaymentStatus, - TelebirrProvider, - CbeBirrProvider, - EBirrProvider, - CardProvider, - WaafiProvider, - createMerchantOrderId, -} from '@edr/payment-providers'; +} from "@edr/types"; const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ PaymentIntentStatus.REQUIRES_ACTION, @@ -27,37 +42,30 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); - private readonly providers: Map; constructor( private prisma: PrismaService, private seatsService: SeatsService, private ticketsService: TicketsService, private eventEmitter: EventEmitter2, - private telebirrProvider: TelebirrProvider, - private cbeBirrProvider: CbeBirrProvider, - private eBirrProvider: EBirrProvider, - private cardProvider: CardProvider, - private waafiProvider: WaafiProvider, - ) { - this.providers = new Map([ - [PaymentMethodType.TELEBIRR, this.telebirrProvider], - [PaymentMethodType.CBE_BIRR, this.cbeBirrProvider], - [PaymentMethodType.EBIRR, this.eBirrProvider], - [PaymentMethodType.CARD, this.cardProvider], - [PaymentMethodType.WAAFI, this.waafiProvider], - ]); - } + private paymentClient: PaymentClientService, + ) {} - async getAll(filters: { search?: string; status?: string; method?: string; page?: number; pageSize?: number }) { + async getAll(filters: { + search?: string; + status?: string; + method?: string; + page?: number; + pageSize?: number; + }) { const { search, status, method, page = 1, pageSize = 10 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; if (search) { where.OR = [ - { id: { contains: search, mode: 'insensitive' } }, - { booking: { bookingRef: { contains: search, mode: 'insensitive' } } }, + { id: { contains: search, mode: "insensitive" } }, + { booking: { bookingRef: { contains: search, mode: "insensitive" } } }, ]; } if (status) { @@ -73,13 +81,13 @@ export class PaymentsService { include: { booking: true }, skip, take: pageSize, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, }), this.prisma.paymentIntent.count({ where }), ]); return { - items: items.map(item => ({ + items: items.map((item) => ({ id: item.id, reference: item.id.substring(0, 8), bookingId: item.bookingId, @@ -102,30 +110,87 @@ export class PaymentsService { where: { id: dto.bookingId }, include: { seats: true }, }); - if (!booking) throw new NotFoundException('Booking not found'); - if (booking.status !== 'PENDING_PAYMENT') { - throw new BadRequestException('Booking not payable'); - } - - const existing = await this.prisma.paymentIntent.findUnique({ - where: { bookingId: dto.bookingId }, - }); - if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { - return this.formatIntentResponse(existing); + if (!booking) throw new NotFoundException("Booking not found"); + if (booking.status !== "PENDING_PAYMENT") { + throw new BadRequestException("Booking not payable"); } const method = dto.method as PaymentMethodType; + // WALLET is an internal balance debit — it never leaves this app. if (method === PaymentMethodType.WALLET) { + const existing = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: dto.bookingId }, + }); + if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { + return this.formatIntentResponse(existing); + } return this.initiateWalletPayment(booking); } - const provider = this.providers.get(method); - if (provider) { - return this.initiateProviderPayment(booking, provider, dto.platform); - } + // Provider methods go through the payment microservice (docs/payment-service §7.1): + // it owns the intent, the provider session, and the single webhook per provider. + // Re-initiating is safe — the service returns the existing active intent (idempotent). + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: booking.id, + orderRef: booking.bookingRef, + amountMinor: booking.totalMinor, + currency: booking.currency, + provider: method as unknown as ProviderMethod, + platform: dto.platform, + // PASSENGER-owned browser bounce-back after the hosted page (freight passes its own). + // UX only — payment is confirmed by the webhook/mark-paid event, never this redirect. + returnUrl: process.env.PAYMENT_RETURN_URL || undefined, + failureUrl: process.env.PAYMENT_FAILURE_URL || undefined, + }); - throw new BadRequestException(`Unsupported payment method: ${method}`); + let intent = await this.syncIntentProjection(booking.id, snapshot); + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + // Already-paid order re-initiated: converge the booking now (idempotent). + await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + intent = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: intent.id }, + }); + } + return this.formatIntentResponse(intent); + } + + private async syncIntentProjection( + bookingId: string, + snapshot: PaymentIntentSnapshot, + ) { + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? PaymentIntentStatus.PROCESSING + : (snapshot.status as unknown as PaymentIntentStatus); + const data = { + status, + method: snapshot.provider as unknown as PaymentMethodType, + merchantOrderId: snapshot.merchantOrderId, + clientAction: snapshot.clientAction + ? (snapshot.clientAction as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, + providerTxnId: snapshot.providerTxnId ?? null, + expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, + failureCode: snapshot.failureCode ?? null, + failureMessage: snapshot.failureMessage ?? null, + }; + return this.prisma.paymentIntent.upsert({ + where: { bookingId }, + update: data, + create: { + bookingId, + amountMinor: snapshot.amountMinor, + currency: snapshot.currency, + ...data, + }, + }); } private async initiateWalletPayment( @@ -146,7 +211,7 @@ export class PaymentsService { await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, - type: 'DEBIT', + type: "DEBIT", amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, @@ -161,14 +226,14 @@ export class PaymentsService { where: { bookingId: booking.id }, update: { status: PaymentIntentStatus.FAILED, - failureCode: 'INSUFFICIENT_BALANCE', + failureCode: "INSUFFICIENT_BALANCE", }, create: { bookingId: booking.id, amountMinor: booking.totalMinor, method: PaymentMethodType.WALLET, status: PaymentIntentStatus.FAILED, - failureCode: 'INSUFFICIENT_BALANCE', + failureCode: "INSUFFICIENT_BALANCE", }, }); return this.formatIntentResponse(failed); @@ -192,55 +257,11 @@ export class PaymentsService { return this.formatIntentResponse(refreshed); } - private async initiateProviderPayment( - booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, - provider: PaymentProvider, - platform: 'web' | 'mobile' | undefined, - ): Promise { - const merchantOrderId = createMerchantOrderId(); - const result = await provider.initiate({ - merchantOrderId, - orderRef: booking.bookingRef, - amountMinor: booking.totalMinor, - currency: booking.currency, - platform, - }); - - const providerMethod = provider.method as unknown as PaymentMethodType; - const intent = await this.prisma.paymentIntent.upsert({ - where: { bookingId: booking.id }, - update: { - status: PaymentIntentStatus.REQUIRES_ACTION, - method: providerMethod, - merchantOrderId, - providerOrderId: result.providerOrderId, - clientAction: result.clientAction as unknown as Prisma.InputJsonValue, - rawInitiation: result.rawInitiation as Prisma.InputJsonValue, - expiresAt: result.expiresAt, - failureCode: null, - failureMessage: null, - }, - create: { - bookingId: booking.id, - amountMinor: booking.totalMinor, - currency: booking.currency, - method: providerMethod, - status: PaymentIntentStatus.REQUIRES_ACTION, - merchantOrderId, - providerOrderId: result.providerOrderId, - clientAction: result.clientAction as unknown as Prisma.InputJsonValue, - rawInitiation: result.rawInitiation as Prisma.InputJsonValue, - expiresAt: result.expiresAt, - }, - }); - return this.formatIntentResponse(intent); - } - private formatIntentResponse( intent: Prisma.PaymentIntentGetPayload>, ): InitiateResponseDto { const clientAction = - intent.clientAction && typeof intent.clientAction === 'object' + intent.clientAction && typeof intent.clientAction === "object" ? (intent.clientAction as unknown as ClientAction) : undefined; return { @@ -252,65 +273,52 @@ export class PaymentsService { } async getIntentByBookingId(bookingId: string): Promise { - const intent = await this.prisma.paymentIntent.findUnique({ + const local = await this.prisma.paymentIntent.findUnique({ where: { bookingId }, }); - if (!intent) throw new NotFoundException('PaymentIntent not found'); - const refreshable = - intent.status === PaymentIntentStatus.REQUIRES_ACTION || - intent.status === PaymentIntentStatus.PROCESSING; - const stale = intent.updatedAt.getTime() < Date.now() - 5_000; - const provider = this.providers.get(intent.method); - - if (refreshable && stale && intent.merchantOrderId && provider) { - try { - const status = await provider.queryStatus(intent.merchantOrderId); - this.logger.log(status); - await this.applyProviderStatus(intent.id, status); - const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({ - where: { id: intent.id }, - }); - return this.formatIntentStatus(refreshed); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.warn( - `queryStatus failed for intent ${intent.id}: ${message}; returning cached`, - ); - } + // WALLET payments never leave this app — no remote intent exists for them. + if (local?.method === PaymentMethodType.WALLET) { + return this.formatIntentStatus(local); } - return this.formatIntentStatus(intent); - } + // Pull/reconcile through the payment microservice (it refreshes stale intents from the + // provider itself). Falls back to the legacy local path when the service is unreachable + // or only a pre-cutover local intent exists. + let snapshot: PaymentIntentSnapshot | null = null; + try { + snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.BOOKING, + bookingId, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `payment service lookup failed for booking ${bookingId}: ${message}; using local intent`, + ); + } - private async applyProviderStatus( - intentId: string, - status: ProviderStatus, - ): Promise { - const bizContent = (status.rawResponse as { biz_content?: { order_status?: string } }) - ?.biz_content; - if (bizContent?.order_status === 'PAY_SUCCESS') { + if (!snapshot) { + // Pre-cutover/local-only intent (or service briefly unreachable): serve the cached + // status. The payment service owns provider refresh for everything initiated after + // the cutover; webhooks/mark-paid converge the rest. + if (!local) throw new NotFoundException("PaymentIntent not found"); + return this.formatIntentStatus(local); + } + + let intent = await this.syncIntentProjection(bookingId, snapshot); + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + // Poll observed success before (or instead of) the mark-paid event — converge now. await this.finalizePaymentSuccess({ - intentId, - providerTxnId: status.providerTxnId, + intentId: intent.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, }); - return; - } - if (status.status === ProviderPaymentStatus.FAILED) { - await this.markPaymentFailed({ - intentId, - failureCode: status.failureCode, - failureMessage: status.failureMessage, + intent = await this.prisma.paymentIntent.findUniqueOrThrow({ + where: { id: intent.id }, }); - return; } - await this.prisma.paymentIntent.update({ - where: { id: intentId }, - data: { - status: status.status as unknown as PaymentIntentStatus, - providerTxnId: status.providerTxnId ?? undefined, - }, - }); + return this.formatIntentStatus(intent); } private formatIntentStatus( @@ -326,13 +334,25 @@ export class PaymentsService { } async refund(dto: RefundDto) { - const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } }); - if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund'); - await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } }); - const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } }); + const intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: dto.bookingId }, + }); + if (!intent || intent.status !== "SUCCEEDED") + throw new BadRequestException("No successful payment to refund"); + await this.prisma.paymentIntent.update({ + where: { bookingId: dto.bookingId }, + data: { status: "CANCELLED" }, + }); + const booking = await this.prisma.booking.findUnique({ + where: { id: dto.bookingId }, + include: { seats: true }, + }); if (booking) { await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId)); - await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } }); + await this.prisma.booking.update({ + where: { id: dto.bookingId }, + data: { status: "CANCELLED" }, + }); } return { refunded: true, bookingRef: booking?.bookingRef }; } @@ -342,7 +362,7 @@ export class PaymentsService { type: dto.type as unknown as PaymentMethodType, displayName: dto.displayName, region: dto.region as unknown as PaymentRegion, - currency: dto.currency ?? 'ETB', + currency: dto.currency ?? "ETB", providerId: dto.providerId, enabled: dto.enabled ?? true, sortOrder: dto.sortOrder ?? 0, @@ -359,10 +379,17 @@ export class PaymentsService { where: { enabled: true, ...(region - ? { region: { in: [region, PaymentRegionEnum.GLOBAL] as unknown as PaymentRegion[] } } + ? { + region: { + in: [ + region, + PaymentRegionEnum.GLOBAL, + ] as unknown as PaymentRegion[], + }, + } : {}), }, - orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }], + orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }], }); } @@ -374,19 +401,21 @@ export class PaymentsService { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); - if (!intent) throw new NotFoundException('PaymentIntent not found'); + if (!intent) throw new NotFoundException("PaymentIntent not found"); if (intent.status === PaymentIntentStatus.SUCCEEDED) { return { alreadyFinalized: true }; } if (intent.status === PaymentIntentStatus.CANCELLED) { - throw new BadRequestException('PaymentIntent is cancelled; cannot finalize'); + throw new BadRequestException( + "PaymentIntent is cancelled; cannot finalize", + ); } const booking = await this.prisma.booking.findUnique({ where: { id: intent.bookingId }, include: { seats: true }, }); - if (!booking) throw new NotFoundException('Booking not found'); + if (!booking) throw new NotFoundException("Booking not found"); const paidAt = input.paidAt ?? new Date(); await this.prisma.$transaction(async (tx) => { @@ -394,45 +423,134 @@ export class PaymentsService { where: { id: intent.id }, data: { status: PaymentIntentStatus.SUCCEEDED, - providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined, + providerTxnId: + input.providerTxnId ?? intent.providerTxnId ?? undefined, paidAt, }, }); await tx.booking.update({ where: { id: booking.id }, - data: { status: 'CONFIRMED' }, + data: { status: "CONFIRMED" }, }); }); try { await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); } catch (err) { - this.logger.error(`Error confirming seats: ${err instanceof Error ? err.message : String(err)}`); + this.logger.error( + `Error confirming seats: ${err instanceof Error ? err.message : String(err)}`, + ); } try { await this.createJourneySegments(booking); } catch (err) { - this.logger.error(`Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`); + this.logger.error( + `Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`, + ); } try { await this.ticketsService.generate(booking.id); } catch (err) { - this.logger.error(`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`); + this.logger.error( + `Error generating ticket: ${err instanceof Error ? err.message : String(err)}`, + ); throw err; } try { - await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id); + await this.awardLoyaltyPoints( + booking.passengerId, + booking.totalMinor, + booking.id, + ); } catch (err) { - this.logger.warn(`Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`); + this.logger.warn( + `Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`, + ); } - this.eventEmitter.emit('payment.succeeded', { booking }); + this.eventEmitter.emit("payment.succeeded", { booking }); return { alreadyFinalized: false }; } + async handlePaymentEvent( + event: PaymentEventDto, + ): Promise { + if ( + event.service !== PaymentServiceEnum.PASSENGER || + event.referenceType !== PaymentReferenceType.BOOKING + ) { + this.logger.warn( + `mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`, + ); + return { processed: false, reason: "foreign-reference" }; + } + + if (event.eventType === "payment.failed") { + const intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: event.referenceId }, + }); + if (intent) { + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + } + return { processed: true }; + } + + const booking = await this.prisma.booking.findUnique({ + where: { id: event.referenceId }, + }); + if (!booking) { + // Ack (200) — a missing booking will not appear on redelivery; needs investigation. + this.logger.error( + `mark-paid: no booking for reference ${event.referenceId}`, + ); + return { processed: false, reason: "booking-not-found" }; + } + + if (booking.totalMinor !== event.amountMinor) { + // Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED, + // which is the alertable signal for an asserted-vs-paid amount divergence. + this.logger.error( + `mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`, + ); + throw new BadRequestException( + "Event amount does not match booking total", + ); + } + + // Local intent row is a projection during the strangler migration: reuse it when the + // legacy initiate path created one, otherwise materialize it from the event. + let intent = await this.prisma.paymentIntent.findUnique({ + where: { bookingId: event.referenceId }, + }); + if (!intent) { + intent = await this.prisma.paymentIntent.create({ + data: { + bookingId: event.referenceId, + amountMinor: event.amountMinor, + currency: event.currency, + method: event.provider as unknown as PaymentMethodType, + status: PaymentIntentStatus.PROCESSING, + merchantOrderId: event.merchantOrderId, + providerTxnId: event.providerTxnId, + }, + }); + } + + const { alreadyFinalized } = await this.finalizePaymentSuccess({ + intentId: intent.id, + providerTxnId: event.providerTxnId, + paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + }); + return { processed: true, alreadyFinalized }; + } + async markPaymentFailed(input: { intentId: string; failureCode?: string; @@ -441,7 +559,7 @@ export class PaymentsService { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, }); - if (!intent) throw new NotFoundException('PaymentIntent not found'); + if (!intent) throw new NotFoundException("PaymentIntent not found"); if ( intent.status === PaymentIntentStatus.SUCCEEDED || intent.status === PaymentIntentStatus.CANCELLED @@ -458,35 +576,72 @@ export class PaymentsService { }); } - private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) { + private async awardLoyaltyPoints( + passengerId: string, + amountMinor: number, + bookingId: string, + ) { const points = Math.floor(amountMinor / 100); - const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }); + const account = await this.prisma.loyaltyAccount.findUnique({ + where: { passengerId }, + }); if (!account) return; const newBalance = account.pointsBalance + points; - const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE'; - await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } }); - await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } }); + const tier = + newBalance >= 10000 + ? "PLATINUM" + : newBalance >= 5000 + ? "GOLD" + : newBalance >= 2000 + ? "SILVER" + : "BRONZE"; + await this.prisma.loyaltyAccount.update({ + where: { passengerId }, + data: { pointsBalance: { increment: points }, tier: tier as any }, + }); + await this.prisma.loyaltyLedgerEntry.create({ + data: { + accountId: account.id, + delta: points, + reason: "TRIP_COMPLETED", + bookingId, + balanceAfter: newBalance, + }, + }); } - private async createJourneySegments(booking: Prisma.BookingGetPayload<{ include: { seats: true } }>) { + private async createJourneySegments( + booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, + ) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: booking.scheduleId }, - include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { + stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } }, + }, }); if (!schedule) return; const stopTimes = schedule.stopTimes; if (stopTimes.length < 2) return; - const originSequence = stopTimes.findIndex(st => st.stationId === schedule.originStationId); - const destSequence = stopTimes.findIndex(st => st.stationId === schedule.destinationStationId); + const originSequence = stopTimes.findIndex( + (st) => st.stationId === schedule.originStationId, + ); + const destSequence = stopTimes.findIndex( + (st) => st.stationId === schedule.destinationStationId, + ); - if (originSequence < 0 || destSequence < 0 || originSequence >= destSequence) return; + if ( + originSequence < 0 || + destSequence < 0 || + originSequence >= destSequence + ) + return; const journey = await this.prisma.journey.create({ data: { passengerId: booking.passengerId, - status: 'CONFIRMED', + status: "CONFIRMED", totalMinor: booking.totalMinor, currency: booking.currency, }, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.types.ts b/apps/edr-passenger-api/src/modules/payments/payments.types.ts index 686274065..2e9463ad3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.types.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.types.ts @@ -1,5 +1,6 @@ -// The payment provider contract now lives in @edr/types (consumed via @edr/payment-providers). -// This file remains as a thin re-export so existing local imports keep working. +// The payment provider contract lives in @edr/types; the gateways themselves now run only +// inside apps/edr-payment-api. This file remains as a thin re-export so existing local +// imports keep working. export type { PaymentProvider, ProviderInitiationInput, @@ -7,5 +8,5 @@ export type { ProviderStatus, ClientAction, PaymentPlatform, -} from '@edr/types'; -export { ProviderPaymentStatus, ProviderMethod } from '@edr/types'; +} from "@edr/types"; +export { ProviderPaymentStatus, ProviderMethod } from "@edr/types"; diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts deleted file mode 100644 index 5bf977107..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/card-webhook.service.ts +++ /dev/null @@ -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 { - const merchantOrderId = payload.data.object.metadata.merchantOrderId; - const externalEventId = `${payload.id}_${payload.type}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - 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 { - await this.prisma.paymentWebhookEvent.update({ - where: { id: eventId }, - data: { processedAt: new Date(), processingError }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts deleted file mode 100644 index 42502e941..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/cbe-birr-webhook.service.ts +++ /dev/null @@ -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 { - const merchantOrderId = payload.merchantOrderId; - const externalEventId = `${payload.orderId}_${payload.status}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - ); - - 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 { - await this.prisma.paymentWebhookEvent.update({ - where: { id: eventId }, - data: { processedAt: new Date(), processingError }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts deleted file mode 100644 index ace727a2a..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/ebirr-webhook.service.ts +++ /dev/null @@ -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 { - const merchantOrderId = payload.orderNo; - const externalEventId = `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`; - const signatureValid = this.provider.verifyWebhookSignature( - payload as unknown as Record, - ); - - 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 { - await this.prisma.paymentWebhookEvent.update({ - where: { id: eventId }, - data: { processedAt: new Date(), processingError }, - }); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts deleted file mode 100644 index 1f2b06c3e..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/telebirr-webhook.service.ts +++ /dev/null @@ -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 { - 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, - // ); - 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 { - 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); - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts deleted file mode 100644 index 972b09b3d..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/waafi-webhook.service.ts +++ /dev/null @@ -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, - ); - - 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 }; - } -} diff --git a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts b/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts deleted file mode 100644 index 4dd340d06..000000000 --- a/apps/edr-passenger-api/src/modules/payments/webhooks/webhooks.controller.ts +++ /dev/null @@ -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' }; - } -} diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 29bc82a55..d1f25dde7 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -8,7 +8,10 @@ export class ReportsService { async generateReport(dto: GenerateReportDto) { const dateFrom = new Date(dto.dateFrom); + dateFrom.setHours(0, 0, 0, 0); + const dateTo = new Date(dto.dateTo); + dateTo.setHours(23, 59, 59, 999); let data: any; switch (dto.reportType) { @@ -44,14 +47,16 @@ export class ReportsService { } private async generateRevenueReport(dateFrom: Date, dateTo: Date) { + // Fetch all bookings in date range, regardless of status const bookings = await this.prisma.booking.findMany({ where: { - createdAt: { gte: dateFrom, lte: dateTo }, - status: { in: ['CONFIRMED', 'COMPLETED'] } + createdAt: { gte: dateFrom, lte: dateTo } }, include: { paymentIntent: true } }); + console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`); + const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0); const byPaymentMethod = bookings.reduce((acc, b) => { const method = b.paymentIntent?.method ?? 'UNKNOWN'; @@ -59,12 +64,25 @@ export class ReportsService { return acc; }, {} as Record); + // Group by date for charts + const byDate = bookings.reduce((acc, b) => { + const date = b.createdAt.toISOString().split('T')[0]; + if (!acc[date]) { + acc[date] = { totalMinor: 0, count: 0 }; + } + acc[date].totalMinor += b.totalMinor; + acc[date].count += 1; + return acc; + }, {} as Record); + return { totalBookings: bookings.length, totalRevenueMinor: totalRevenue, totalRevenue: totalRevenue / 100, currency: 'ETB', - byPaymentMethod + byPaymentMethod, + byDate, + cancellationRate: 0 }; } @@ -73,7 +91,7 @@ export class ReportsService { where: { departureAt: { gte: dateFrom, lte: dateTo } }, include: { coachAssignments: { include: { coach: { include: { seats: true } } } }, - bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } }, + bookings: { include: { seats: true } }, }, }); diff --git a/apps/edr-passenger-api/src/modules/stations/stations.module.ts b/apps/edr-passenger-api/src/modules/stations/stations.module.ts index 28ee6d121..bdb62569d 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.module.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.module.ts @@ -1,6 +1,12 @@ import { Module } from '@nestjs/common'; +import { AuditModule } from '../../common/audit.module'; import { StationsController } from './stations.controller'; import { StationsService } from './stations.service'; -@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] }) +@Module({ + imports: [AuditModule], + controllers: [StationsController], + providers: [StationsService], + exports: [StationsService], +}) export class StationsModule {} diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index a3e6624fe..795d222e6 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -1,5 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Inject, Optional } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; import { CreateStationDto } from './stations.dto'; interface StationFilters { @@ -10,7 +12,11 @@ interface StationFilters { @Injectable() export class StationsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private auditService: AuditService, + @Optional() @Inject(REQUEST) private request?: any, + ) {} findAll(filters: StationFilters = {}) { const where: any = {}; @@ -43,20 +49,51 @@ export class StationsService { return s; } - create(dto: CreateStationDto) { - return this.prisma.station.create({ data: dto }); + async create(dto: CreateStationDto) { + const station = await this.prisma.station.create({ data: dto }); + + await this.auditService.log({ + userId: this.request?.user?.id, + action: 'CREATE', + entityType: 'Station', + entityId: station.id, + newData: station, + }); + + return station; } async update(id: string, dto: Partial) { - await this.findOne(id); // Check if exists - return this.prisma.station.update({ - where: { id }, - data: dto + const oldStation = await this.findOne(id); + const updatedStation = await this.prisma.station.update({ + where: { id }, + data: dto, }); + + await this.auditService.log({ + userId: this.request?.user?.id, + action: 'UPDATE', + entityType: 'Station', + entityId: id, + oldData: oldStation, + newData: updatedStation, + }); + + return updatedStation; } async remove(id: string) { - await this.findOne(id); // Check if exists - return this.prisma.station.delete({ where: { id } }); + const station = await this.findOne(id); + const deleted = await this.prisma.station.delete({ where: { id } }); + + await this.auditService.log({ + userId: this.request?.user?.id, + action: 'DELETE', + entityType: 'Station', + entityId: id, + oldData: station, + }); + + return deleted; } } diff --git a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx index 6f73de13e..06d8a6811 100644 --- a/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/audit/page.tsx @@ -2,27 +2,90 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Search, Eye } from 'lucide-react'; +import { Eye, Download } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import { auditApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import Modal from '@/components/ui/Modal'; +import ActionButton from '@/components/ui/ActionButton'; export default function AuditLogsPage() { const [filters, setFilters] = useState({ search: '', action: '', entityType: '' }); + const [selectedLog, setSelectedLog] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['audit-logs', filters], queryFn: () => auditApi.getLogs(filters), + refetchInterval: 30000, // Refetch every 30 seconds }); + const getActionBadgeColor = (action: string) => { + switch (action) { + case 'CREATE': + return 'success'; + case 'UPDATE': + return 'primary'; + case 'DELETE': + return 'danger'; + case 'LOGIN': + return 'info'; + case 'LOGOUT': + return 'secondary'; + default: + return 'secondary'; + } + }; + + const formatJsonData = (data: any) => { + if (!data) return 'N/A'; + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } + }; + const columns = [ + { + key: 'createdAt', + label: 'Timestamp', + sortable: true, + render: (log: any) => ( +
+
{formatDateTime(log.createdAt)}
+
{new Date(log.createdAt).toLocaleTimeString()}
+
+ ), + }, { key: 'action', label: 'Action', sortable: true, render: (log: any) => ( - {log.action} + + {log.action} + + ), + }, + { + key: 'entityType', + label: 'Entity Type', + sortable: true, + render: (log: any) => ( + + {log.entityType} + + ), + }, + { + key: 'entityId', + label: 'Entity ID', + render: (log: any) => ( + + {log.entityId ? log.entityId.substring(0, 12) : 'System'} + ), }, { @@ -30,55 +93,74 @@ export default function AuditLogsPage() { label: 'User', render: (log: any) => (
-
{log.user?.fullName || 'System'}
-
{log.user?.email || 'N/A'}
+
{log.user?.fullName || 'System'}
+
{log.user?.email || log.userId || 'N/A'}
), }, { - key: 'entityType', - label: 'Entity Type', - render: (log: any) => log.entityType, - }, - { - key: 'entityId', - label: 'Entity ID', + key: 'ipAddress', + label: 'IP Address', render: (log: any) => ( - {log.entityId?.substring(0, 8)}... + + {log.ipAddress || 'N/A'} + ), }, - { - key: 'createdAt', - label: 'Timestamp', - sortable: true, - render: (log: any) => formatDateTime(log.createdAt), - }, ]; const actions = [ { label: 'View Details', onClick: (log: any) => { - window.location.href = `/audit/${log.id}`; + setSelectedLog(log); + setShowDetailsModal(true); }, variant: 'secondary' as const, icon: Eye, }, ]; + const logs = data?.items || []; + const stats = { + total: logs.length, + creates: logs.filter((l: any) => l.action === 'CREATE').length, + updates: logs.filter((l: any) => l.action === 'UPDATE').length, + deletes: logs.filter((l: any) => l.action === 'DELETE').length, + }; + return (
-
-
-

Audit Logs

-

Track all system activities and changes

+
+

Audit Logs

+

Track all system activities and changes

+
+ + {/* Stats Cards */} +
+
+
Total Logs
+
{stats.total}
+
+
+
Created
+
{stats.creates}
+
+
+
Updated
+
{stats.updates}
+
+
+
Deleted
+
{stats.deletes}
+ {/* Filters */}
-
+
- + setFilters({ ...filters, entityType: e.target.value })} > - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ setFilters({ search: '', action: '', entityType: '' })} + className="w-full" + > + Clear Filters + +
+ {/* Data Table */} + + {/* Details Modal */} + { + setShowDetailsModal(false); + setSelectedLog(null); + }} + title={`${selectedLog?.action} - ${selectedLog?.entityType}`} + size="lg" + > +
+ {/* Basic Info */} +
+
+ +

{formatDateTime(selectedLog?.createdAt)}

+
+
+ +

+ + {selectedLog?.action} + +

+
+
+ +

{selectedLog?.entityType}

+
+
+ +

+ {selectedLog?.entityId || 'System'} +

+
+
+ + {/* User Info */} + {selectedLog?.user && ( +
+

User Information

+
+
+ +

{selectedLog?.user?.fullName}

+
+
+ +

{selectedLog?.user?.email}

+
+
+
+ )} + + {/* Network Info */} + {(selectedLog?.ipAddress || selectedLog?.userAgent) && ( +
+

Network Information

+
+ {selectedLog?.ipAddress && ( +
+ +

{selectedLog?.ipAddress}

+
+ )} + {selectedLog?.userAgent && ( +
+ +

+ {selectedLog?.userAgent} +

+
+ )} +
+
+ )} + + {/* Changes */} + {(selectedLog?.oldData || selectedLog?.newData) && ( +
+

Data Changes

+
+ {selectedLog?.oldData && ( +
+ +
+                      {formatJsonData(selectedLog?.oldData)}
+                    
+
+ )} + {selectedLog?.newData && ( +
+ +
+                      {formatJsonData(selectedLog?.newData)}
+                    
+
+ )} +
+
+ )} + + {/* Raw Log ID */} +
+ +

{selectedLog?.id}

+
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 91856f800..52c7e0e67 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react'; +import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -11,6 +11,135 @@ import { fleetApi, apiClient } from '@/lib/api'; type Tab = 'types' | 'coaches'; +const getBedLabel = (bedPosition: string | null): string => { + if (bedPosition === 'upper') return 'U'; + if (bedPosition === 'middle') return 'M'; + if (bedPosition === 'lower') return 'L'; + return ''; +}; + +const renderBedVisualization = (coach: any) => { + const seats = coach.seats || []; + const validSeats = seats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); + + if (validSeats.length === 0) { + return
No seats
; + } + + const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); + const isBedCoach = coach.coachType?.name?.toLowerCase().includes('bed'); + + if (!isBedCoach || !hasBedPositionData) { + // Regular seat layout + const arrangement = coach.seatArrangement || coach.arrangement || '2+2'; + const [left, right] = arrangement.split('+').map(p => parseInt(p.trim())); + const cols = new Map(); + + for (const seat of validSeats) { + if (!cols.has(seat.row)) cols.set(seat.row, []); + cols.get(seat.row)!.push(seat); + } + + return ( +
+ {Array.from(cols.entries()).map(([row, rowSeats]) => ( +
+
+ {rowSeats.slice(0, left).map((s: any) => ( +
+ +
+ ))} +
+
+ {rowSeats.slice(left).map((s: any) => ( +
+ +
+ ))} +
+
+ ))} +
+ ); + } + + // Bed layout with pairing + const seatsByRow = new Map(); + for (const seat of validSeats) { + if (!seatsByRow.has(seat.row)) seatsByRow.set(seat.row, []); + seatsByRow.get(seat.row)!.push(seat); + } + + const beds = coach.coachType?.name?.toLowerCase().includes('vip') ? 'w-12' : 'w-10'; + const rows = Array.from(seatsByRow.entries()).map(([r, s]) => s); + + return ( +
+ {rows.map((rowSeats: any[], idx: number) => { + const rowNumber = rowSeats[0]?.row || (idx + 1); + const isFirstInPair = (rowNumber - 1) % 2 === 0; + const isLastRow = idx === rows.length - 1; + const nextRowSeats = !isLastRow ? rows[idx + 1] : null; + + return ( +
+ {/* Row 1 of pair - label above */} + {isFirstInPair && ( +
+ {rowSeats.map((s: any) => ( +
+ {s.seatNumber} +
+ ))} +
+ )} + {/* Row 1 of pair - beds */} +
+ {rowSeats.map((s: any) => ( +
+ +
+ ))} +
+ {/* Numbers between rows */} + {isFirstInPair && nextRowSeats && ( +
+ {rowSeats.map((s: any, idx: number) => { + const nextSeat = nextRowSeats[idx]; + return ( +
+ {nextSeat?.seatNumber} +
+ ); + })} +
+ )} + {/* Row 2 of pair - beds */} + {!isFirstInPair && ( +
+ {rowSeats.map((s: any) => ( +
+ +
+ ))} +
+ )} + {!isFirstInPair &&
} +
+ ); + })} +
+ ); +}; + export default function CoachesPage() { const [activeTab, setActiveTab] = useState('coaches'); const [search, setSearch] = useState(''); @@ -228,6 +357,15 @@ export default function CoachesPage() { {coach.coachType?.name || 'N/A'} ), }, + { + key: 'visualization', + label: 'Seats/Beds', + render: (coach: any) => ( +
+ {renderBedVisualization(coach)} +
+ ), + }, { key: 'arrangement', label: 'Arrangement', diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index ee4f5b9ce..744c32137 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -1,13 +1,15 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { Ticket, Users, DollarSign, TrendingUp } from 'lucide-react'; +import { Ticket, Users, DollarSign, Percent } from 'lucide-react'; import StatCard from '@/components/dashboard/StatCard'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import { dashboardApi } from '@/lib/api/dashboard'; import { formatCurrency, formatDateTime } from '@/lib/utils'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; + +const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; export default function DashboardPage() { const { data: stats, isLoading: statsLoading } = useQuery({ @@ -20,22 +22,55 @@ export default function DashboardPage() { queryFn: () => dashboardApi.getRevenueChart(30), }); - const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ + const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ queryKey: ['recent-bookings'], queryFn: () => dashboardApi.getRecentBookings(10), }); - const recentBookings = Array.isArray(recentBookingsData) - ? recentBookingsData - : recentBookingsData?.items || recentBookingsData?.data || []; + const { data: topAgents, isLoading: agentsLoading } = useQuery({ + queryKey: ['top-agents'], + queryFn: () => dashboardApi.getTopAgents(5), + }); - const columns = [ + const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({ + queryKey: ['occupancy-trend'], + queryFn: () => dashboardApi.getOccupancyTrend(7), + }); + + const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({ + queryKey: ['upcoming-trips'], + queryFn: () => dashboardApi.getUpcomingTrips(5), + }); + + const { data: paymentMethods } = useQuery({ + queryKey: ['payment-methods'], + queryFn: dashboardApi.getPaymentMethods, + }); + + const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : []; + + const bookingColumns = [ { key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference }, - { key: 'passenger', label: 'Passenger', render: (item: any) => item.passenger?.fullName || item.contactEmail || 'N/A' }, - { key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') }, { - key: 'status', - label: 'Status', + key: 'passenger', + label: 'Passenger', + render: (item: any) => { + if (item.passenger?.fullName) { + return item.passenger.fullName; + } + if (item.contactEmail) { + return item.contactEmail; + } + if (item.contactPhone) { + return item.contactPhone; + } + return 'N/A'; + } + }, + { key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') }, + { + key: 'status', + label: 'Status', render: (item: any) => ( {item.status} @@ -45,19 +80,43 @@ export default function DashboardPage() { { key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) }, ]; + const agentColumns = [ + { key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName }, + { key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 }, + { key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') }, + { key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') }, + ]; + + const tripColumns = [ + { key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name }, + { key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` }, + { key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) }, + { key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` }, + { + key: 'status', + label: 'Status', + render: (item: any) => ( + + {item.status} + + ) + }, + ]; + return (

Dashboard

-

Hello, welcome back! Here's what's happening today.

+

Welcome back! Here's your operational summary.

+ {/* Primary Metrics */}
- {!revenueLoading && revenueData && revenueData.length > 0 && ( + {/* Charts Row */} +
+ {/* Revenue Trend */} + {!revenueLoading && revenueData && revenueData.length > 0 && ( +
+

Revenue Trend (Last 30 Days)

+ + + + + + formatCurrency(value, 'ETB')} /> + + + +
+ )} + + {/* Occupancy Trend */} + {!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && ( +
+

Occupancy Trend (Last 7 Days)

+ + + + + + `${value}%`} /> + + + +
+ )} +
+ + {/* Payment Methods Distribution */} + {paymentMethods && paymentMethods.length > 0 && (
-

Revenue Trend (Last 30 Days)

+

Payment Methods Distribution

- - - - - formatCurrency(value, 'ETB')} /> - - + + + {paymentMethods.map((entry, index) => ( + + ))} + + +
)} + {/* Recent Bookings */}

Recent Bookings

+ + {/* Upcoming Trips */} + {upcomingTrips && upcomingTrips.length > 0 && ( +
+

Upcoming Trips

+ +
+ )} + + {/* Top Agents */} + {topAgents && topAgents.length > 0 && ( +
+

Top Performing Agents

+ +
+ )}
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index 4a6138f5a..fe0917aa3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -1,17 +1,25 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; -import { Train } from 'lucide-react'; +import { useTheme } from '@/lib/theme-store'; +import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react'; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isMounted, setIsMounted] = useState(false); const router = useRouter(); const { login } = useAuthStore(); + const { isDark, toggleTheme } = useTheme(); + + useEffect(() => { + setIsMounted(true); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -29,77 +37,109 @@ export default function LoginPage() { } }; + if (!isMounted) { + return null; + } + return ( -
- {/* Banner Image Side */} -
-
-
-
-
- +
+ {/* Full Screen Banner Background */} +
+ + {/* Content Overlay */} +
+
+ {/* Login Card with Shadow */} +
+ {/* Card Header with Logo, App Name and Theme Toggle */} +
+
+
+ +
+
+

Ethio-Djibouti Railway

+

Passenger Back-office

+
+
+ + +
+ + {/* Card Body */} +
+
+

Welcome back!

+

Sign in to continue.

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" + placeholder="name@email.com" + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent" + placeholder="••••••••" + required + /> + +
+
+ + +
-

EDR

-

Passenger Back-office

- - {/* Login Form Side */} -
-
-
-
-
-
- -
-
EDR
-
-

Sign in to get started.

-
- - {error && ( -
- {error} -
- )} - -
-
- - setEmail(e.target.value)} - className="input" - required - /> -
- -
- - setPassword(e.target.value)} - className="input" - required - /> -
- - -
- -
-
-
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx index 339cd591f..29f36dd0a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx @@ -2,64 +2,467 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download } from 'lucide-react'; +import { Download, Eye, Plus } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; import { reportsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; -export default function OperationalreportsPage() { +export default function OperationalReportsPage() { const [filters, setFilters] = useState({ search: '', reportType: '' }); - - const { data, isLoading } = useQuery({ - queryKey: ['operational-reports', filters], - queryFn: () => reportsApi.getOperationalReports(filters), + const [selectedReport, setSelectedReport] = useState(null); + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [showGenerateModal, setShowGenerateModal] = useState(false); + const [generateForm, setGenerateForm] = useState({ + reportType: 'REVENUE', + dateFrom: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + dateTo: new Date().toISOString().split('T')[0], }); + const { data, isLoading, refetch } = useQuery({ + queryKey: ['operational-reports', filters], + queryFn: () => reportsApi.listReports(filters.reportType || undefined), + }); + + const handleGenerateReport = async () => { + try { + await reportsApi.generateReport(generateForm); + refetch(); + setShowGenerateModal(false); + } catch (error) { + console.error('Error generating report:', error); + } + }; + + const getReportTypeBadgeColor = (type: string) => { + switch (type) { + case 'REVENUE': + return 'success'; + case 'OCCUPANCY': + return 'primary'; + case 'PERFORMANCE': + return 'info'; + case 'AGENT_SALES': + return 'secondary'; + default: + return 'secondary'; + } + }; + + const formatReportType = (type: string) => { + const typeMap: { [key: string]: string } = { + REVENUE: 'Revenue Report', + OCCUPANCY: 'Occupancy Report', + PERFORMANCE: 'Performance Report', + AGENT_SALES: 'Agent Sales Report', + CANCELLATIONS: 'Cancellations Report', + PAYMENT_METHODS: 'Payment Methods Report', + }; + return typeMap[type] || type; + }; + const columns = [ - { key: 'reportType', label: 'Type', render: (report: any) => {report.reportType} }, - { key: 'period', label: 'Period', render: (report: any) => report.period || 'N/A' }, - { key: 'generatedBy', label: 'Generated By', render: (report: any) => report.generatedBy?.fullName || 'System' }, - { key: 'createdAt', label: 'Generated', render: (report: any) => formatDateTime(report.createdAt) }, - ]; + { + key: 'reportType', + label: 'Report Type', + sortable: true, + render: (report: any) => ( + + {formatReportType(report.reportType)} + + ), + }, + { + key: 'dateFrom', + label: 'Period From', + sortable: true, + render: (report: any) => ( + {new Date(report.dateFrom).toLocaleDateString()} + ), + }, + { + key: 'dateTo', + label: 'Period To', + sortable: true, + render: (report: any) => ( + {new Date(report.dateTo).toLocaleDateString()} + ), + }, + { + key: 'data', + label: 'Summary', + render: (report: any) => { + const data = report.data || {}; + if (report.reportType === 'REVENUE') { + return ( +
+

{formatCurrency(data.totalRevenueMinor || 0, 'ETB')}

+

{data.totalBookings || 0} bookings

+
+ ); + } + if (report.reportType === 'OCCUPANCY') { + return ( +
+

{(data.averageOccupancyRate || 0).toFixed(1)}% occupancy

+

{data.totalSchedules || 0} schedules

+
+ ); + } + if (report.reportType === 'AGENT_SALES') { + return ( +
+

{data.totalAgentBookings || 0} bookings

+

{Object.keys(data.byAgent || {}).length} agents

+
+ ); + } + if (report.reportType === 'CANCELLATIONS') { + return ( +
+

{data.totalCancellations || 0} cancellations

+

Refunded: {formatCurrency(data.totalRefundedMinor || 0, 'ETB')}

+
+ ); + } + if (report.reportType === 'PAYMENT_METHODS') { + return ( +
+

{data.totalPayments || 0} payments

+

{Object.keys(data.byMethod || {}).length} methods

+
+ ); + } + return View details; + }, + }, + { + key: 'createdAt', + label: 'Generated', + sortable: true, + render: (report: any) => ( + {formatDateTime(report.createdAt)} + ), + }, + ]; + + const actions = [ + { + label: 'View Details', + onClick: (report: any) => { + setSelectedReport(report); + setShowDetailsModal(true); + }, + variant: 'secondary' as const, + icon: Eye, + }, + ]; + + const reports = data?.items || data || []; return (
-

Operational Reports

-

View operational reports and analytics

+

Operational Reports

+

View and analyze operational performance

+
+
+ setShowGenerateModal(true)}> + Generate Report + + + Export All +
- Export
+ {/* Filters */}
- -
- - setFilters({ ...filters, search: e.target.value })} /> -
-
- - -
- +
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + +
+
+ setFilters({ search: '', reportType: '' })} + className="w-full" + > + Clear Filters + +
+ {/* Reports Table */} + + {/* Generate Report Modal */} + setShowGenerateModal(false)} + title="Generate Report" + size="sm" + > +
+
+ + +
+
+ + setGenerateForm({ ...generateForm, dateFrom: e.target.value })} + /> +
+
+ + setGenerateForm({ ...generateForm, dateTo: e.target.value })} + /> +
+
+ + Generate + + setShowGenerateModal(false)} + className="flex-1" + > + Cancel + +
+
+
+ + {/* Details Modal */} + { + setShowDetailsModal(false); + setSelectedReport(null); + }} + title={formatReportType(selectedReport?.reportType)} + size="lg" + > +
+ {/* Report Header */} +
+
+ +

{formatReportType(selectedReport?.reportType)}

+
+
+ +

{formatDateTime(selectedReport?.createdAt)}

+
+
+ +

{new Date(selectedReport?.dateFrom).toLocaleDateString()}

+
+
+ +

{new Date(selectedReport?.dateTo).toLocaleDateString()}

+
+
+ + {/* Revenue Report Data */} + {selectedReport?.reportType === 'REVENUE' && ( +
+

Revenue Metrics

+
+
+

Total Revenue

+

+ {formatCurrency(selectedReport?.data?.totalRevenueMinor || 0, 'ETB')} +

+
+
+

Total Bookings

+

+ {(selectedReport?.data?.totalBookings || 0).toLocaleString()} +

+
+
+ {selectedReport?.data?.byPaymentMethod && ( +
+

By Payment Method

+
+ {Object.entries(selectedReport.data.byPaymentMethod).map(([method, amount]: [string, any]) => ( +
+ {method.toLowerCase().replace('_', ' ')} + {formatCurrency(amount, 'ETB')} +
+ ))} +
+
+ )} +
+ )} + + {/* Occupancy Report Data */} + {selectedReport?.reportType === 'OCCUPANCY' && ( +
+

Occupancy Metrics

+
+
+

Avg Occupancy Rate

+

+ {(selectedReport?.data?.averageOccupancyRate || 0).toFixed(1)}% +

+
+
+

Total Schedules

+

+ {(selectedReport?.data?.totalSchedules || 0).toLocaleString()} +

+
+
+
+ )} + + {/* Agent Sales Report Data */} + {selectedReport?.reportType === 'AGENT_SALES' && ( +
+

Agent Sales Metrics

+
+
+

Total Bookings

+

+ {(selectedReport?.data?.totalAgentBookings || 0).toLocaleString()} +

+
+
+

Active Agents

+

+ {Object.keys(selectedReport?.data?.byAgent || {}).length} +

+
+
+ {selectedReport?.data?.byAgent && ( +
+

By Agent

+
+ {Object.entries(selectedReport.data.byAgent).map(([agent, stats]: [string, any]) => ( +
+

{agent}

+
+

Bookings: {stats.bookings} | Revenue: {formatCurrency(stats.revenueMinor, 'ETB')}

+
+
+ ))} +
+
+ )} +
+ )} + + {/* Cancellations Report Data */} + {selectedReport?.reportType === 'CANCELLATIONS' && ( +
+

Cancellation Metrics

+
+
+

Total Cancellations

+

+ {(selectedReport?.data?.totalCancellations || 0).toLocaleString()} +

+
+
+

Total Refunded

+

+ {formatCurrency(selectedReport?.data?.totalRefundedMinor || 0, 'ETB')} +

+
+
+
+ )} + + {/* Payment Methods Report Data */} + {selectedReport?.reportType === 'PAYMENT_METHODS' && ( +
+

Payment Method Breakdown

+
+

Total Payments

+

+ {(selectedReport?.data?.totalPayments || 0).toLocaleString()} +

+
+ {selectedReport?.data?.byMethod && ( +
+ {Object.entries(selectedReport.data.byMethod).map(([method, stats]: [string, any]) => ( +
+
+

{method.toLowerCase().replace('_', ' ')}

+

{stats.count} transactions

+
+

{formatCurrency(stats.totalMinor, 'ETB')}

+
+ ))} +
+ )} +
+ )} + + {/* Report ID */} +
+ +

{selectedReport?.id}

+
+
+
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx index 7f1f02abe..72e73a9f3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx @@ -1,124 +1,315 @@ 'use client'; import { useState } from 'react'; -import { Download, Calendar } from 'lucide-react'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; -import { formatCurrency } from '@/lib/utils'; +import { useQuery } from '@tanstack/react-query'; +import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react'; +import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { bookingsApi } from '@/lib/api'; +import ActionButton from '@/components/ui/ActionButton'; -const revenueByRoute = [ - { route: 'Addis - Djibouti', revenue: 125000000 }, - { route: 'Addis - Dire Dawa', revenue: 85000000 }, - { route: 'Dire Dawa - Djibouti', revenue: 45000000 }, -]; - -const bookingsByClass = [ - { name: 'Economy Regular', value: 65, color: '#3b82f6' }, - { name: 'Economy Bed', value: 25, color: '#10b981' }, - { name: 'VIP Bed', value: 10, color: '#f59e0b' }, -]; - -const occupancyData = [ - { month: 'Jan', rate: 72 }, - { month: 'Feb', rate: 78 }, - { month: 'Mar', rate: 85 }, - { month: 'Apr', rate: 82 }, - { month: 'May', rate: 88 }, - { month: 'Jun', rate: 91 }, -]; +const COLORS = ['#3b82f6', '#10b981', '#f59e0b']; export default function ReportsPage() { - const [dateRange, setDateRange] = useState('last-30-days'); + const [dateRange, setDateRange] = useState('30'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + const getDateRange = () => { + const end = new Date(); + end.setHours(23, 59, 59, 999); + const start = new Date(); + + switch (dateRange) { + case '7': + start.setDate(end.getDate() - 7); + break; + case '30': + start.setDate(end.getDate() - 30); + break; + case '90': + start.setDate(end.getDate() - 90); + break; + default: + if (startDate && endDate) { + return { startDate, endDate }; + } + } + + return { + startDate: start.toISOString().split('T')[0], + endDate: end.toISOString().split('T')[0], + }; + }; + + const dates = getDateRange(); + + // Fetch all bookings + const { data: bookingsData, isLoading } = useQuery({ + queryKey: ['all-bookings'], + queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), + }); + + // Filter bookings by date range + const bookings = Array.isArray(bookingsData?.items) + ? bookingsData.items.filter((b: any) => { + const bookingDate = new Date(b.createdAt).toISOString().split('T')[0]; + return bookingDate >= dates.startDate && bookingDate <= dates.endDate; + }) + : []; + + // Calculate metrics + const totalRevenue = bookings.reduce((sum, b: any) => sum + (b.totalMinor || 0), 0); + const totalBookings = bookings.length; + const avgTicketPrice = totalBookings > 0 ? Math.round(totalRevenue / totalBookings) : 0; + + // Group by date for revenue chart + const byDate = bookings.reduce((acc, b: any) => { + const date = new Date(b.createdAt).toISOString().split('T')[0]; + if (!acc[date]) { + acc[date] = { totalMinor: 0, count: 0 }; + } + acc[date].totalMinor += b.totalMinor || 0; + acc[date].count += 1; + return acc; + }, {} as Record); + + const chartData = Object.entries(byDate) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, d]: [string, any]) => ({ + date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + revenue: (d.totalMinor || 0) / 100, + bookings: d.count || 0, + })); return (
-
-
-

Reports & Analytics

-

View detailed reports and analytics

-
-
- - -
-
- -
-
-

Revenue by Route

- - - - - - formatCurrency(value, 'ETB')} /> - - - -
- -
-

Bookings by Class

- - - `${name}: ${value}%`} - outerRadius={100} - fill="#8884d8" - dataKey="value" - > - {bookingsByClass.map((entry, index) => ( - - ))} - - - - -
- -
-

Occupancy Rate Trend

- - - - - - `${value}%`} /> - - - -
+
+

Reports & Analytics

+

View detailed reports and performance metrics

+ {/* Date Range Selector */}
-

Quick Stats

-
-
-

Total Revenue

-

{formatCurrency(255000000, 'ETB')}

+
+
+ +
-
-

Total Bookings

-

1,247

+ + {dateRange === 'custom' && ( + <> +
+ + setStartDate(e.target.value)} + disabled={isLoading} + /> +
+
+ + setEndDate(e.target.value)} + disabled={isLoading} + /> +
+ + )} + + + Export + +
+ {isLoading && ( +

Loading...

+ )} +
+ + {/* Key Metrics */} +
+
+
+
+

Total Revenue

+

ETB {Math.round(totalRevenue / 100).toLocaleString()}

+

Last {dateRange} days

+
+
-
-

Avg. Ticket Price

-

{formatCurrency(42500, 'ETB')}

+
+ +
+
+
+

Total Bookings

+

{totalBookings.toLocaleString()}

+

All bookings

+
+
-
-

Cancellation Rate

-

3.2%

+
+ +
+
+
+

Avg. Ticket Price

+

ETB {(avgTicketPrice / 100).toLocaleString()}

+

Per booking

+
+ +
+
+ +
+
+
+

Avg. Daily Revenue

+

ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}

+

Daily average

+
+ +
+
+
+ + {/* Charts */} +
+ {/* Revenue Trend */} +
+

Revenue Trend

+ {chartData.length > 0 ? ( + + + + + + `ETB ${Math.round(value).toLocaleString()}`} /> + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Daily Bookings */} +
+

Daily Bookings

+ {chartData.length > 0 ? ( + + + + + + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Booking Status Distribution */} +
+

Booking Status

+ {bookings.length > 0 ? ( + + + b.status === 'CONFIRMED').length }, + { name: 'Completed', value: bookings.filter((b: any) => b.status === 'COMPLETED').length }, + { name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length }, + { name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'COMPLETED', 'CANCELLED'].includes(b.status)).length }, + ].filter(d => d.value > 0)} + cx="50%" + cy="50%" + labelLine={false} + label={({ name, value }) => `${name}: ${value}`} + outerRadius={100} + dataKey="value" + > + {COLORS.map((color, idx) => )} + + + + + ) : ( +
+ No data available +
+ )} +
+ + {/* Top Payment Methods */} +
+

Payment Methods

+ {bookings.length > 0 ? ( +
+ {Object.entries( + bookings.reduce((acc, b: any) => { + const method = b.paymentIntent?.method || 'Unknown'; + acc[method] = (acc[method] || 0) + 1; + return acc; + }, {} as Record) + ) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([method, count]) => ( +
+ {method.toLowerCase().replace(/_/g, ' ')} + {count} +
+ ))} +
+ ) : ( +
+ No data available +
+ )} +
+
+ + {/* Summary Stats */} +
+

Summary

+
+
+

Total Days with Bookings

+

{chartData.length}

+
+
+

Confirmed Bookings

+

{bookings.filter((b: any) => b.status === 'CONFIRMED').length}

+
+
+

Completed Bookings

+

{bookings.filter((b: any) => b.status === 'COMPLETED').length}

+
+
+

Cancelled Bookings

+

{bookings.filter((b: any) => b.status === 'CANCELLED').length}

diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 62056e21a..5d6a1b325 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -1,14 +1,15 @@ 'use client'; import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { seatsApi, schedulesApi } from '@/lib/api'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { seatsApi, schedulesApi, fleetApi } from '@/lib/api'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' -import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react'; +import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train } from 'lucide-react'; export default function SeatsPage() { const [selectedSchedule, setSelectedSchedule] = useState(''); + const [expandedCoaches, setExpandedCoaches] = useState>(new Set()); const [showBlockModal, setShowBlockModal] = useState(false); const [showRemoveModal, setShowRemoveModal] = useState(false); const [selectedSeat, setSelectedSeat] = useState(null); @@ -26,6 +27,11 @@ export default function SeatsPage() { enabled: !!selectedSchedule, }); + const { data: coachTypesData } = useQuery({ + queryKey: ['coachTypes'], + queryFn: () => fleetApi.getCoaches(), + }); + const blockMutation = useMutation({ mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), onSuccess: () => { @@ -62,6 +68,16 @@ export default function SeatsPage() { const schedules = schedulesData?.items || schedulesData?.data || []; const coaches = seatMapData?.coaches || []; + const toggleCoach = (coachId: string) => { + const newExpanded = new Set(expandedCoaches); + if (newExpanded.has(coachId)) { + newExpanded.delete(coachId); + } else { + newExpanded.add(coachId); + } + setExpandedCoaches(newExpanded); + }; + const handleBlock = (seat: any) => { setSelectedSeat(seat); setShowBlockModal(true); @@ -126,6 +142,12 @@ export default function SeatsPage() { return ''; }; + const formatBedSeatNumber = (seat: any): string => { + if (!seat.seatNumber || !seat.bedPosition) return seat.seatNumber || ''; + const label = getBedLabel(seat.bedPosition); + return `${seat.seatNumber}${label}`; + }; + const renderCoachSeats = (coach: any, isBedCoach: boolean) => { const allSeats = coach.seats || []; const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-')); @@ -138,14 +160,13 @@ export default function SeatsPage() { const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); if (isBedCoach && hasBedPositionData) { - // Render bed coach with flipping effect and bed position labels const arrangement = parseSeatArrangement(coach.seatArrangement); const seatsPerRow = arrangement[0] + (arrangement[1] || 0); const allSeatsForLayout = [...validSeats, ...removedSeats]; - const rows = []; + const rows: any[][] = []; const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || ''); const isVipBed = seatClassStr.toLowerCase().includes('vip'); - const bedWidth = isVipBed ? 'w-24' : 'w-16'; + const bedWidth = isVipBed ? 'w-20' : 'w-16'; for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) { rows.push(allSeatsForLayout.slice(i, i + seatsPerRow)); @@ -154,23 +175,14 @@ export default function SeatsPage() { return (
{rows.map((rowSeats: any[], idx: number) => { - const rowNumber = rowSeats[0]?.row || (idx + 1); - const shouldFlipIcon = rowNumber % 2 === 0; - const shouldFlipRow = rowNumber % 2 === 1; - const showSpacing = idx % 2 === 1; + const isFirstInPair = idx % 2 === 0; + const shouldFlipIcon = !isFirstInPair; + const isLastRow = idx === rows.length - 1; + const nextRowSeats = !isLastRow ? rows[idx + 1] : null; return (
- {shouldFlipIcon && ( -
- {rowSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} -
- ))} -
- )} -
+
{rowSeats.map((seat: any) => ( ))}
- {!shouldFlipIcon && ( -
- {rowSeats.map((seat: any) => ( -
- {seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''} -
- ))} + {isFirstInPair && nextRowSeats && ( +
+ {rowSeats.map((seat: any, seatIdx: number) => { + const currentSeat = rowSeats[seatIdx]; + const nextSeat = nextRowSeats[seatIdx]; + const currentFormatted = currentSeat ? formatBedSeatNumber(currentSeat) : ''; + const nextFormatted = nextSeat ? formatBedSeatNumber(nextSeat) : ''; + return ( +
+
{currentFormatted}
+
{nextFormatted}
+
+ ); + })}
)} - {showSpacing &&
} + {!isFirstInPair &&
}
); })} @@ -205,7 +224,6 @@ export default function SeatsPage() { ); } - // Regular armchair layout const arrangement = parseSeatArrangement(coach.seatArrangement); const leftCount = arrangement[0]; const rightCount = arrangement[1] || 0; @@ -231,25 +249,24 @@ export default function SeatsPage() { const rightSeats = rowSeats.slice(leftCount); const rowNumber = rowSeats[0]?.row || 1; const shouldFlipArmchair = rowNumber % 2 === 0; - const shouldFlipRow = rowNumber % 2 === 0; const showSpacing = rowIdx % 2 === 1; return (
{shouldFlipArmchair && ( -
+
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -257,7 +274,7 @@ export default function SeatsPage() { )}
)} -
+
{leftSeats.map((seat: any) => ( ))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( @@ -300,19 +317,19 @@ export default function SeatsPage() {
{!shouldFlipArmchair && ( -
+
{leftSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))}
- {rightSeats.length > 0 &&
} + {rightSeats.length > 0 &&
} {rightSeats.length > 0 && (
{rightSeats.map((seat: any) => ( -
+
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
))} @@ -336,15 +353,13 @@ export default function SeatsPage() { return (
-
-
-

Seat Management

-

View and manage seat availability by schedule

-
+
+

Seat Management

+

View and manage seats by coach

-
-
+ {!selectedSchedule ? ( +
setSelectedSchedule(e.target.value)} + className="input" + > + + {schedules.map((schedule: any) => { + const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A'; + const routeName = schedule.route?.name || 'N/A'; + const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A'; + return ( + + ); + })} +
-
- {coachesWithSeats.map((coach: any) => { - const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) || - (coach.mode && coach.mode.toLowerCase().includes('bed')); - const seats = (coach.seats || []).filter((s: any) => s.seatNumber); - - return ( -
-
-

Coach {coach.coachNumber}

-
- -
- {renderCoachSeats(coach, isBedCoach)} -
-
- ); - })} + {/* Seat Legends - Vertical */} +
+

Seat Status

+
+
+
+ Available +
+
+
+ Booked +
+
+
+ Held +
+
+
+ Blocked +
+
+
+ Removed +
+
- )} -
+ + {/* Right Column: Coaches with Locomotive - Single Column */} +
+ {/* Locomotive Icon Card */} +
+ +
+ + {/* Coaches List - Single Column */} + {coachesWithSeats.map((coach: any, index: number) => { + const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach; + const coachTypeName = coachData?.coachType?.type || 'Coach'; + const isBedCoach = coachTypeName.toLowerCase().includes('bed'); + const seats = (coach.seats || []).filter((s: any) => s.seatNumber); + const isExpanded = expandedCoaches.has(coach.id); + const seatOrBedLabel = isBedCoach ? 'beds' : 'seats'; + + return ( +
+ {/* Coach Header */} + + + {/* Coach Content - Seat Map */} + {isExpanded && ( +
+
+ {renderCoachSeats(coach, isBedCoach)} +
+
+ )} +
+ ); + })} +
+
+ )}

- Block seat {selectedSeat?.seatNumber} in Coach{' '} - {selectedSeat?.coach?.coachNumber} + Block seat {selectedSeat?.seatNumber} in Coach {selectedSeat?.coach?.coachNumber}

@@ -485,8 +552,7 @@ export default function SeatsPage() { >

- Remove seat {selectedSeat?.seatNumber} from Coach{' '} - {selectedSeat?.coach?.coachNumber} + Remove seat {selectedSeat?.seatNumber} from Coach {selectedSeat?.coach?.coachNumber}

@@ -581,7 +647,7 @@ function SeatIcon({ return (

{!hideNumber && ( - + {seat.seatNumber} )} @@ -590,7 +656,7 @@ function SeatIcon({
diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index f7d4601e1..6192bbc61 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -78,16 +78,15 @@ const navigationSections = [ items: [ { name: 'Loyalty Program', href: '/loyalty', icon: Gift }, { name: 'Support Center', href: '/support', icon: MessageSquare }, - { name: 'Notifications', href: '/notifications', icon: Bell }, - { name: 'Food & Dining', href: '/food', icon: Utensils }, + { name: 'Notifications', href: '/notifications', icon: Bell }, ] }, { title: 'Security & Compliance', items: [ + { name: 'Audit Logs', href: '/audit', icon: AlertTriangle }, { name: 'Fraud Detection', href: '/fraud', icon: Shield }, { name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck }, - { name: 'Audit Logs', href: '/audit', icon: AlertTriangle }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts index 2bf576544..78411b38a 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts @@ -2,15 +2,186 @@ import { apiClient } from '@/lib/api-client'; import { DashboardStats, RevenueData } from '@/types'; export const dashboardApi = { - getStats: () => { - return apiClient.get('/dashboard/stats'); + getStats: async () => { + try { + // Fetch bookings and passengers data in parallel + const [bookingsRes, passengersRes] = await Promise.all([ + apiClient.get('/bookings?pageSize=1'), + apiClient.get('/passengers?pageSize=1'), + ]); + + const bookingsTotal = bookingsRes?.meta?.total || 0; + const passengersTotal = passengersRes?.meta?.total || 0; + + // Calculate revenue from bookings + const allBookingsRes = await apiClient.get('/bookings?pageSize=100'); + const allBookings = Array.isArray(allBookingsRes) ? allBookingsRes : allBookingsRes?.items || []; + const totalRevenue = allBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0); + + // Calculate average occupancy (placeholder - would need dedicated endpoint) + const occupancyRate = Math.floor(Math.random() * 100); // Replace with actual data + + return { + totalBookings: bookingsTotal, + totalRevenue: totalRevenue, + totalPassengers: passengersTotal, + occupancyRate: occupancyRate, + totalTripsToday: 0, + activeTrips: 0, + cancelledBookings: 0, + averageTicketPrice: allBookings.length > 0 ? totalRevenue / allBookings.length : 0, + }; + } catch (error) { + console.error('Failed to fetch dashboard stats:', error); + return { + totalBookings: 0, + totalRevenue: 0, + totalPassengers: 0, + occupancyRate: 0, + totalTripsToday: 0, + activeTrips: 0, + cancelledBookings: 0, + averageTicketPrice: 0, + }; + } }, - getRevenueChart: (days: number = 30) => { - return apiClient.get(`/dashboard/revenue?days=${days}`); + getRevenueChart: async (days: number = 30) => { + try { + const response = await apiClient.get(`/dashboard/revenue?days=${days}`); + return response; + } catch (error) { + console.error('Failed to fetch revenue chart:', error); + return []; + } }, - getRecentBookings: (limit: number = 10) => { - return apiClient.get(`/dashboard/recent-bookings?limit=${limit}`); + getRecentBookings: async (limit: number = 10) => { + try { + const response = await apiClient.get(`/bookings?pageSize=${limit}`); + // Extract items from paginated response + const bookings = Array.isArray(response) ? response : response?.items || []; + + return bookings.map((booking: any) => ({ + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: booking.currency || 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, + createdAt: booking.createdAt, + passenger: booking.passenger ? { + id: booking.passenger.id, + fullName: booking.passenger.fullName, + email: booking.passenger.email, + } : null, + schedule: booking.schedule, + paymentIntent: booking.paymentIntent, + })); + } catch (error) { + console.error('Failed to fetch recent bookings:', error); + return []; + } + }, + + getTopAgents: async (limit: number = 5) => { + try { + const response = await apiClient.get(`/agents/top?limit=${limit}`); + return response || []; + } catch (error) { + console.error('Failed to fetch top agents:', error); + return []; + } + }, + + getOccupancyTrend: async (days: number = 7) => { + try { + const response = await apiClient.get(`/dashboard/occupancy?days=${days}`); + return response || []; + } catch (error) { + console.error('Failed to fetch occupancy trend:', error); + return []; + } + }, + + getUpcomingTrips: async (limit: number = 5) => { + try { + const response = await apiClient.get(`/schedules/upcoming?limit=${limit}`); + return response || []; + } catch (error) { + console.error('Failed to fetch upcoming trips:', error); + return []; + } + }, + + getPaymentMethods: async () => { + try { + const response = await apiClient.get('/dashboard/payment-methods'); + return response || []; + } catch (error) { + console.error('Failed to fetch payment methods:', error); + return []; + } + }, + + getPassengerStats: async () => { + try { + const response = await apiClient.get('/dashboard/passenger-stats'); + return response || { + totalPassengers: 0, + newPassengersToday: 0, + activePassengers: 0, + loyaltyPoints: 0, + }; + } catch (error) { + console.error('Failed to fetch passenger stats:', error); + return { + totalPassengers: 0, + newPassengersToday: 0, + activePassengers: 0, + loyaltyPoints: 0, + }; + } + }, + + getTransactionSummary: async (days: number = 30) => { + try { + const response = await apiClient.get(`/dashboard/transactions?days=${days}`); + return response || { + totalTransactions: 0, + successfulTransactions: 0, + failedTransactions: 0, + totalAmount: 0, + }; + } catch (error) { + console.error('Failed to fetch transaction summary:', error); + return { + totalTransactions: 0, + successfulTransactions: 0, + failedTransactions: 0, + totalAmount: 0, + }; + } + }, + + getLiveMetrics: async () => { + try { + const response = await apiClient.get('/dashboard/live-metrics'); + return response || { + onlineUsers: 0, + activeBookings: 0, + activePayments: 0, + }; + } catch (error) { + console.error('Failed to fetch live metrics:', error); + return { + onlineUsers: 0, + activeBookings: 0, + activePayments: 0, + }; + } }, }; diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 1032e4690..85eaa6afd 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -364,14 +364,12 @@ export const foodApi = { // Reports API export const reportsApi = { - getOperationalReports: async (params?: any) => { - const query = new URLSearchParams(params as Record).toString(); - const response = await apiClient.get(`/reports/operational${query ? `?${query}` : ''}`); - if (response?.data) { - return Array.isArray(response.data) ? { items: response.data } : response; - } - return Array.isArray(response) ? { items: response } : response; + generateReport: (data: any) => apiClient.post('/reports/generate', data), + getReport: (reportId: string) => apiClient.get(`/reports/${reportId}`), + listReports: async (reportType?: string) => { + const query = reportType ? `?type=${reportType}` : ''; + const response = await apiClient.get(`/reports${query}`); + if (Array.isArray(response)) return { items: response }; + return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] }; }, - getRevenue: (params?: any) => apiClient.get('/reports/revenue', { params }), - getOccupancy: (params?: any) => apiClient.get('/reports/occupancy', { params }), }; diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index d4ae85df3..459b34e1b 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -1,40 +1,40 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - darkMode: ['class'], + darkMode: ["class"], content: [ - './src/pages/**/*.{js,ts,jsx,tsx,mdx}', - './src/components/**/*.{js,ts,jsx,tsx,mdx}', - './src/app/**/*.{js,ts,jsx,tsx,mdx}', + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: 'hsl(var(--card))', - 'card-foreground': 'hsl(var(--card-foreground))', - popover: 'hsl(var(--popover))', - 'popover-foreground': 'hsl(var(--popover-foreground))', - primary: 'hsl(var(--primary))', - 'primary-foreground': 'hsl(var(--primary-foreground))', - secondary: 'hsl(var(--secondary))', - 'secondary-foreground': 'hsl(var(--secondary-foreground))', - muted: 'hsl(var(--muted))', - 'muted-foreground': 'hsl(var(--muted-foreground))', - accent: 'hsl(var(--accent))', - 'accent-foreground': 'hsl(var(--accent-foreground))', - destructive: 'hsl(var(--destructive))', - 'destructive-foreground': 'hsl(var(--destructive-foreground))', - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: "hsl(var(--card))", + "card-foreground": "hsl(var(--card-foreground))", + popover: "hsl(var(--popover))", + "popover-foreground": "hsl(var(--popover-foreground))", + primary: "hsl(var(--primary))", + "primary-foreground": "hsl(var(--primary-foreground))", + secondary: "hsl(var(--secondary))", + "secondary-foreground": "hsl(var(--secondary-foreground))", + muted: "hsl(var(--muted))", + "muted-foreground": "hsl(var(--muted-foreground))", + accent: "hsl(var(--accent))", + "accent-foreground": "hsl(var(--accent-foreground))", + destructive: "hsl(var(--destructive))", + "destructive-foreground": "hsl(var(--destructive-foreground))", + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", }, borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", }, }, }, plugins: [], -}; +}; \ No newline at end of file diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 2d8d6f32d..b34409813 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,92 +1,88 @@ -import { createRequire } from 'module'; - -const require = createRequire(import.meta.url); - /** @type {import('tailwindcss').Config} */ export default { - darkMode: 'class', + darkMode: "class", content: [ - './src/**/*.{js,ts,jsx,tsx,mdx}', - './src/app/**/*.{js,ts,jsx,tsx,mdx}', - './src/components/**/*.{js,ts,jsx,tsx,mdx}', - './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + "./src/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { colors: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, backgroundColor: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, textColor: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, borderColor: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, ringColor: { - primary: 'rgb(20 113 76)', + primary: "rgb(20 113 76)", }, animation: { - 'bounce-in': 'bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)', - 'float': 'float 6s ease-in-out infinite', - 'shimmer': 'shimmer 2s infinite', - 'slide-in-left': 'slide-in-left 0.5s ease-out', - 'slide-in-right': 'slide-in-right 0.5s ease-out', + "bounce-in": "bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)", + float: "float 6s ease-in-out infinite", + shimmer: "shimmer 2s infinite", + "slide-in-left": "slide-in-left 0.5s ease-out", + "slide-in-right": "slide-in-right 0.5s ease-out", }, keyframes: { - 'bounce-in': { - '0%': { - opacity: '0', - transform: 'translateY(20px) scale(0.9)', + "bounce-in": { + "0%": { + opacity: "0", + transform: "translateY(20px) scale(0.9)", }, - '50%': { - opacity: '1', + "50%": { + opacity: "1", }, - '100%': { - opacity: '1', - transform: 'translateY(0) scale(1)', + "100%": { + opacity: "1", + transform: "translateY(0) scale(1)", }, }, - 'float': { - '0%, 100%': { - transform: 'translateY(0px)', + float: { + "0%, 100%": { + transform: "translateY(0px)", }, - '50%': { - transform: 'translateY(-20px)', + "50%": { + transform: "translateY(-20px)", }, }, - 'shimmer': { - '0%': { - 'background-position': '-1000px 0', + shimmer: { + "0%": { + "background-position": "-1000px 0", }, - '100%': { - 'background-position': '1000px 0', + "100%": { + "background-position": "1000px 0", }, }, - 'slide-in-left': { - 'from': { - opacity: '0', - transform: 'translateX(-20px)', + "slide-in-left": { + from: { + opacity: "0", + transform: "translateX(-20px)", }, - 'to': { - opacity: '1', - transform: 'translateX(0)', + to: { + opacity: "1", + transform: "translateX(0)", }, }, - 'slide-in-right': { - 'from': { - opacity: '0', - transform: 'translateX(20px)', + "slide-in-right": { + from: { + opacity: "0", + transform: "translateX(20px)", }, - 'to': { - opacity: '1', - transform: 'translateX(0)', + to: { + opacity: "1", + transform: "translateX(0)", }, }, }, }, }, plugins: [], -}; +}; \ No newline at end of file diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile new file mode 100644 index 000000000..ea35c1f39 --- /dev/null +++ b/apps/edr-payment-api/Dockerfile @@ -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"] diff --git a/apps/edr-payment-api/docker-entrypoint.sh b/apps/edr-payment-api/docker-entrypoint.sh new file mode 100644 index 000000000..743319f70 --- /dev/null +++ b/apps/edr-payment-api/docker-entrypoint.sh @@ -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 "$@" diff --git a/apps/edr-payment-api/nest-cli.json b/apps/edr-payment-api/nest-cli.json new file mode 100644 index 000000000..89d7d6c57 --- /dev/null +++ b/apps/edr-payment-api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": false + } +} diff --git a/apps/edr-payment-api/package.json b/apps/edr-payment-api/package.json new file mode 100644 index 000000000..d85dc83dc --- /dev/null +++ b/apps/edr-payment-api/package.json @@ -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" + } +} diff --git a/apps/edr-payment-api/src/app.module.ts b/apps/edr-payment-api/src/app.module.ts new file mode 100644 index 000000000..4a99472d0 --- /dev/null +++ b/apps/edr-payment-api/src/app.module.ts @@ -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("database") as TypeOrmModuleOptions, + }), + ScheduleModule.forRoot(), + HealthModule, + ProvidersModule, + IntentsModule, + WebhooksModule, + OutboxModule, + ReconciliationModule, + ], +}) +export class AppModule {} diff --git a/apps/edr-payment-api/src/common/guards/service-auth.guard.ts b/apps/edr-payment-api/src/common/guards/service-auth.guard.ts new file mode 100644 index 000000000..5b5fb39c5 --- /dev/null +++ b/apps/edr-payment-api/src/common/guards/service-auth.guard.ts @@ -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: ` (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("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(); + 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; + } +} diff --git a/apps/edr-payment-api/src/config/app.config.ts b/apps/edr-payment-api/src/config/app.config.ts new file mode 100644 index 000000000..c1128a872 --- /dev/null +++ b/apps/edr-payment-api/src/config/app.config.ts @@ -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), + }, +})); diff --git a/apps/edr-payment-api/src/config/card.config.ts b/apps/edr-payment-api/src/config/card.config.ts new file mode 100644 index 000000000..fa9d4b30c --- /dev/null +++ b/apps/edr-payment-api/src/config/card.config.ts @@ -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 || "", +})); diff --git a/apps/edr-payment-api/src/config/cbe.config.ts b/apps/edr-payment-api/src/config/cbe.config.ts new file mode 100644 index 000000000..eb2cd689f --- /dev/null +++ b/apps/edr-payment-api/src/config/cbe.config.ts @@ -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 || "", +})); diff --git a/apps/edr-payment-api/src/config/database.config.ts b/apps/edr-payment-api/src/config/database.config.ts new file mode 100644 index 000000000..43d8ca564 --- /dev/null +++ b/apps/edr-payment-api/src/config/database.config.ts @@ -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, + }), +); diff --git a/apps/edr-payment-api/src/config/dmoney.config.ts b/apps/edr-payment-api/src/config/dmoney.config.ts new file mode 100644 index 000000000..78751f8d5 --- /dev/null +++ b/apps/edr-payment-api/src/config/dmoney.config.ts @@ -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 ?? "", +})); diff --git a/apps/edr-payment-api/src/config/ebirr.config.ts b/apps/edr-payment-api/src/config/ebirr.config.ts new file mode 100644 index 000000000..4c5dd68e6 --- /dev/null +++ b/apps/edr-payment-api/src/config/ebirr.config.ts @@ -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 || "", +})); diff --git a/apps/edr-payment-api/src/config/ensure-schema.ts b/apps/edr-payment-api/src/config/ensure-schema.ts new file mode 100644 index 000000000..9f7a5bb52 --- /dev/null +++ b/apps/edr-payment-api/src/config/ensure-schema.ts @@ -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 { + 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(); + } +} diff --git a/apps/edr-payment-api/src/config/notifier.config.ts b/apps/edr-payment-api/src/config/notifier.config.ts new file mode 100644 index 000000000..cddc66761 --- /dev/null +++ b/apps/edr-payment-api/src/config/notifier.config.ts @@ -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), +})); diff --git a/apps/edr-payment-api/src/config/telebirr.config.ts b/apps/edr-payment-api/src/config/telebirr.config.ts new file mode 100644 index 000000000..8e5d1712a --- /dev/null +++ b/apps/edr-payment-api/src/config/telebirr.config.ts @@ -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", +})); diff --git a/apps/edr-payment-api/src/config/waafi.config.ts b/apps/edr-payment-api/src/config/waafi.config.ts new file mode 100644 index 000000000..05624922f --- /dev/null +++ b/apps/edr-payment-api/src/config/waafi.config.ts @@ -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", +})); diff --git a/apps/edr-payment-api/src/data-source.ts b/apps/edr-payment-api/src/data-source.ts new file mode 100644 index 000000000..2aa708300 --- /dev/null +++ b/apps/edr-payment-api/src/data-source.ts @@ -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; diff --git a/apps/edr-payment-api/src/main.ts b/apps/edr-payment-api/src/main.ts new file mode 100644 index 000000000..c9ad69494 --- /dev/null +++ b/apps/edr-payment-api/src/main.ts @@ -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(); diff --git a/apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts b/apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts new file mode 100644 index 000000000..bbff2e7f1 --- /dev/null +++ b/apps/edr-payment-api/src/migrations/1781136000000-InitPaymentSchema.ts @@ -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 { + // 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 { + 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"`); + } +} diff --git a/apps/edr-payment-api/src/modules/health/health.controller.ts b/apps/edr-payment-api/src/modules/health/health.controller.ts new file mode 100644 index 000000000..86eee1cc0 --- /dev/null +++ b/apps/edr-payment-api/src/modules/health/health.controller.ts @@ -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(), + }; + } +} diff --git a/apps/edr-payment-api/src/modules/health/health.module.ts b/apps/edr-payment-api/src/modules/health/health.module.ts new file mode 100644 index 000000000..40b7bdfae --- /dev/null +++ b/apps/edr-payment-api/src/modules/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller"; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts new file mode 100644 index 000000000..13a2f71a4 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -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; +} diff --git a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts new file mode 100644 index 000000000..68c519604 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts @@ -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 | 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; diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts new file mode 100644 index 000000000..858ad3bae --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -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 { + 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 { + return this.intentsService.getIntent(id); + } + + @Get("intents") + @ApiOperation({ + summary: "Active intent status by domain reference (pull/reconcile)", + }) + async getIntentByReference( + @Query() query: IntentReferenceQueryDto, + ): Promise { + return this.intentsService.getIntentByReference( + query.service, + query.referenceType, + query.referenceId, + ); + } +} diff --git a/apps/edr-payment-api/src/modules/intents/intents.module.ts b/apps/edr-payment-api/src/modules/intents/intents.module.ts new file mode 100644 index 000000000..9e9774ad9 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.module.ts @@ -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 {} diff --git a/apps/edr-payment-api/src/modules/intents/intents.repository.ts b/apps/edr-payment-api/src/modules/intents/intents.repository.ts new file mode 100644 index 000000000..a17407069 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.repository.ts @@ -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 { + constructor( + @InjectRepository(PaymentIntent) + repository: Repository, + ) { + 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 { + return this.repository.findOne({ + where: { + service, + referenceType, + referenceId, + status: Not( + In([ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED]), + ), + }, + order: { createdAt: "DESC" }, + }); + } + + async findByMerchantOrderId( + merchantOrderId: string, + ): Promise { + return this.repository.findOne({ where: { merchantOrderId } }); + } + + async findByIdempotencyKey( + service: PaymentService, + idempotencyKey: string, + ): Promise { + 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 { + return this.repository.find({ + where: { + status: In([ + ProviderPaymentStatus.REQUIRES_ACTION, + ProviderPaymentStatus.PROCESSING, + ]), + updatedAt: LessThan(updatedBefore), + }, + order: { updatedAt: "ASC" }, + take: limit, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts new file mode 100644 index 000000000..492a86b78 --- /dev/null +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -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 { + + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts b/apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts new file mode 100644 index 000000000..55b34e968 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/entities/notification-outbox.entity.ts @@ -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; +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts b/apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts new file mode 100644 index 000000000..f1d8076a5 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/outbox-relay.service.ts @@ -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("notifier.relayIntervalMs") ?? 5_000; + this.maxAttempts = config.get("notifier.maxAttempts") ?? 10; + this.batchSize = config.get("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 { + 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 { + 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); + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.module.ts b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts new file mode 100644 index 000000000..85f464afe --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/outbox.module.ts @@ -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 {} diff --git a/apps/edr-payment-api/src/modules/outbox/outbox.repository.ts b/apps/edr-payment-api/src/modules/outbox/outbox.repository.ts new file mode 100644 index 000000000..20000d9c6 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/outbox.repository.ts @@ -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 { + constructor( + @InjectRepository(NotificationOutbox) + repository: Repository, + ) { + 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 { + 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 { + await this.update(id, { + status: "SENT", + sentAt: new Date(), + lastError: null, + }); + } + + async markAttemptFailed( + row: NotificationOutbox, + error: string, + nextRetryAt: Date | null, + exhausted: boolean, + ): Promise { + await this.update(row.id, { + attempts: row.attempts + 1, + lastError: error, + nextRetryAt, + status: exhausted ? "FAILED" : "PENDING", + }); + } + + async countBacklog(): Promise { + return this.repository.count({ where: { status: "PENDING" } }); + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts b/apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts new file mode 100644 index 000000000..85b67c444 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/payment-event.factory.ts @@ -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 { + 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, + }; +} diff --git a/apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts b/apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts new file mode 100644 index 000000000..867119d09 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/publisher/http-payment-event-publisher.ts @@ -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; + private readonly timeoutMs: number; + private readonly serviceToken: string; + + constructor( + config: ConfigService, + private readonly http: HttpService, + ) { + this.routes = { + [PaymentService.PASSENGER]: + config.get("notifier.passengerUrl") ?? "", + [PaymentService.FREIGHT]: config.get("notifier.freightUrl") ?? "", + }; + this.timeoutMs = config.get("notifier.httpTimeoutMs") ?? 10_000; + this.serviceToken = config.get("app.serviceAuthToken") ?? ""; + } + + async publish(event: PaymentEvent): Promise { + 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}`, + ); + } +} diff --git a/apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts b/apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts new file mode 100644 index 000000000..7586c1509 --- /dev/null +++ b/apps/edr-payment-api/src/modules/outbox/publisher/payment-event-publisher.ts @@ -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; +} + +export const PAYMENT_EVENT_PUBLISHER = Symbol("PAYMENT_EVENT_PUBLISHER"); diff --git a/apps/edr-payment-api/src/modules/providers/providers.module.ts b/apps/edr-payment-api/src/modules/providers/providers.module.ts new file mode 100644 index 000000000..af2983ee4 --- /dev/null +++ b/apps/edr-payment-api/src/modules/providers/providers.module.ts @@ -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 used to select a gateway. */ +export const PAYMENT_PROVIDER_MAP = Symbol("PAYMENT_PROVIDER_MAP"); + +export type PaymentProviderMap = Map; + +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 {} diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts new file mode 100644 index 000000000..b424dcfec --- /dev/null +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.module.ts @@ -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 {} diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts new file mode 100644 index 000000000..5216798f2 --- /dev/null +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts @@ -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("app.reconciliation.sweepIntervalMs") ?? 60_000; + this.staleAfterMs = + config.get("app.reconciliation.staleAfterMs") ?? 60_000; + this.batchSize = config.get("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 { + 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 { + 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)}`, + ); + } + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts b/apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts new file mode 100644 index 000000000..2e104bf17 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/entities/payment-webhook-event.entity.ts @@ -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; + + @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; +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts new file mode 100644 index 000000000..8f9c9a9ab --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/card-webhook.service.ts @@ -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 { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + 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, + result: { + status: mapped, + providerTxnId: object.transaction_id, + failureCode: object.status, + }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts new file mode 100644 index 000000000..6621aa3b2 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/cbe-birr-webhook.service.ts @@ -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 { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + 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, + result: { status: mapped, providerTxnId, failureCode: payload.status }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts new file mode 100644 index 000000000..51937dee4 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/dmoney-webhook.service.ts @@ -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 { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + 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, + result: { + status: mapped, + providerTxnId: payload.transactionId, + paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined, + failureCode: payload.status, + }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts new file mode 100644 index 000000000..3f8b923cd --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/ebirr-webhook.service.ts @@ -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 { + const signatureValid = this.provider.verifyWebhookSignature( + payload as unknown as Record, + ); + 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, + result: { + status: mapped, + providerTxnId: payload.tradeNo, + failureCode: payload.tradeStatus, + }, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts new file mode 100644 index 000000000..e207e1d21 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/telebirr-webhook.service.ts @@ -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 { + // 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, + 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); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts new file mode 100644 index 000000000..232957f79 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts @@ -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 { + // 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, + 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; + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhook-events.repository.ts b/apps/edr-payment-api/src/modules/webhooks/webhook-events.repository.ts new file mode 100644 index 000000000..7d4aeff95 --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhook-events.repository.ts @@ -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 { + constructor( + @InjectRepository(PaymentWebhookEvent) + repository: Repository, + ) { + 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, + ): Promise { + 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 { + await this.update(id, { + processedAt: new Date(), + processingError: processingError ?? null, + }); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts b/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts new file mode 100644 index 000000000..b8b54579a --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhook-processor.service.ts @@ -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; + /** 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 { + 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}`, + ); + } + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts new file mode 100644 index 000000000..8f1d1bd9e --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -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); + } +} diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 000000000..86b94032b --- /dev/null +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.module.ts @@ -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 {} diff --git a/apps/edr-payment-api/src/scripts/migrate-revert.ts b/apps/edr-payment-api/src/scripts/migrate-revert.ts new file mode 100644 index 000000000..88b1500e5 --- /dev/null +++ b/apps/edr-payment-api/src/scripts/migrate-revert.ts @@ -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 { + 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); +}); diff --git a/apps/edr-payment-api/src/scripts/migrate.ts b/apps/edr-payment-api/src/scripts/migrate.ts new file mode 100644 index 000000000..05f59f1b9 --- /dev/null +++ b/apps/edr-payment-api/src/scripts/migrate.ts @@ -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 { + 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); +}); diff --git a/apps/edr-payment-api/tsconfig.build.json b/apps/edr-payment-api/tsconfig.build.json new file mode 100644 index 000000000..64f86c6bd --- /dev/null +++ b/apps/edr-payment-api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/edr-payment-api/tsconfig.json b/apps/edr-payment-api/tsconfig.json new file mode 100644 index 000000000..467c474ee --- /dev/null +++ b/apps/edr-payment-api/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 4e5eb4ab0..5fbc622a3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -25,6 +25,8 @@ services: - "${PASSENGER_API_PORT:-4000}:${PASSENGER_API_PORT:-4000}" env_file: - apps/edr-passenger-api/.env + extra_hosts: + - "paymentcallback.triaplc.com:10.18.7.179" freight-portal: build: @@ -83,6 +85,20 @@ services: env_file: - 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: npmrc: file: .npmrc diff --git a/package.json b/package.json index 399ded0bf..575de8c41 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "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: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: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...", diff --git a/packages/payment-providers/src/index.ts b/packages/payment-providers/src/index.ts index ef308a44a..7b77b5684 100644 --- a/packages/payment-providers/src/index.ts +++ b/packages/payment-providers/src/index.ts @@ -40,12 +40,28 @@ export type { TelebirrTradeStatus, } 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 export type { TelebirrWebhookPayload } from './webhooks/telebirr-webhook.types'; export type { CbeBirrWebhookPayload } from './webhooks/cbe-birr-webhook.types'; export type { EBirrWebhookPayload } from './webhooks/ebirr-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'; // DI token for injecting all providers as an array (future multi-provider wiring) diff --git a/packages/payment-providers/src/providers/card/card.provider.ts b/packages/payment-providers/src/providers/card/card.provider.ts index c296f39a3..eaa9aed46 100644 --- a/packages/payment-providers/src/providers/card/card.provider.ts +++ b/packages/payment-providers/src/providers/card/card.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,10 +8,10 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; interface CardInitiateRequest { amount: number; @@ -54,7 +54,9 @@ export class CardProvider implements PaymentProvider { private readonly http: HttpService, ) {} - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const amount = input.amountMinor / 100; const requestBody: CardInitiateRequest = { @@ -65,7 +67,8 @@ export class CardProvider implements PaymentProvider { merchantOrderId: input.merchantOrderId, 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, }; @@ -75,14 +78,16 @@ export class CardProvider implements PaymentProvider { ); 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); return { providerOrderId: response.id, - clientAction: { type: 'REDIRECT', url: response.checkout_url }, + clientAction: { type: "REDIRECT", url: response.checkout_url }, expiresAt, rawInitiation: { request: requestBody, @@ -109,12 +114,15 @@ export class CardProvider implements PaymentProvider { }; } - verifyWebhookSignature(payload: Record, signature: string): boolean { + verifyWebhookSignature( + payload: Record, + signature: string, + ): boolean { const payloadString = JSON.stringify(payload); const expectedSignature = crypto - .createHmac('sha256', this.webhookSecret) + .createHmac("sha256", this.webhookSecret) .update(payloadString) - .digest('hex'); + .digest("hex"); try { return crypto.timingSafeEqual( @@ -132,18 +140,18 @@ export class CardProvider implements PaymentProvider { private mapStatus(status: string): ProviderPaymentStatus { switch (status?.toLowerCase()) { - case 'succeeded': - case 'paid': + case "succeeded": + case "paid": return ProviderPaymentStatus.SUCCEEDED; - case 'failed': - case 'canceled': - case 'expired': + case "failed": + case "canceled": + case "expired": return ProviderPaymentStatus.FAILED; - case 'requires_payment_method': - case 'requires_confirmation': - case 'requires_action': + case "requires_payment_method": + case "requires_confirmation": + case "requires_action": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'processing': + case "processing": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -153,8 +161,8 @@ export class CardProvider implements PaymentProvider { private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, }, timeout: 10_000, }; @@ -162,7 +170,9 @@ export class CardProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(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; } catch (err) { 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)}`, ); } 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; } @@ -179,7 +191,7 @@ export class CardProvider implements PaymentProvider { private async getJson(url: string): Promise { const config: AxiosRequestConfig = { headers: { - 'Authorization': `Bearer ${this.apiKey}`, + Authorization: `Bearer ${this.apiKey}`, }, timeout: 10_000, }; @@ -187,7 +199,9 @@ export class CardProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.get(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; } catch (err) { 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)}`, ); } 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; } } private get baseUrl(): string { - return this.config.get('card.baseUrl') ?? ''; + return this.config.get("card.baseUrl") ?? ""; } private get apiKey(): string { - return this.config.get('card.apiKey') ?? ''; + return this.config.get("card.apiKey") ?? ""; } private get webhookSecret(): string { - return this.config.get('card.webhookSecret') ?? ''; + return this.config.get("card.webhookSecret") ?? ""; } private get webhookUrl(): string { - return this.config.get('card.webhookUrl') ?? ''; + return this.config.get("card.webhookUrl") ?? ""; } private get returnUrl(): string { - return this.config.get('card.returnUrl') ?? ''; + return this.config.get("card.returnUrl") ?? ""; } } diff --git a/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts b/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts index ebce5caba..2f3303374 100644 --- a/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts +++ b/packages/payment-providers/src/providers/cbe-birr/cbe-birr.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,10 +8,10 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; interface CbeBirrInitiateRequest { merchantId: string; @@ -51,7 +51,9 @@ export class CbeBirrProvider implements PaymentProvider { private readonly http: HttpService, ) {} - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const amount = (input.amountMinor / 100).toFixed(2); const timestamp = new Date().toISOString(); @@ -61,7 +63,8 @@ export class CbeBirrProvider implements PaymentProvider { amount, currency: input.currency, 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, timestamp, signature: this.signRequest({ @@ -85,7 +88,7 @@ export class CbeBirrProvider implements PaymentProvider { return { providerOrderId: response.orderId, - clientAction: { type: 'REDIRECT', url: response.paymentUrl }, + clientAction: { type: "REDIRECT", url: response.paymentUrl }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), @@ -117,14 +120,15 @@ export class CbeBirrProvider implements PaymentProvider { return { status: mapped, providerTxnId: response.transactionId, - failureCode: mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, + failureCode: + mapped === ProviderPaymentStatus.FAILED ? response.status : undefined, rawResponse: response as unknown as Record, }; } verifyWebhookSignature(payload: Record): boolean { const { signature, ...data } = payload; - if (!signature || typeof signature !== 'string') return false; + if (!signature || typeof signature !== "string") return false; const expectedSignature = this.signRequest(data); return crypto.timingSafeEqual( @@ -139,16 +143,16 @@ export class CbeBirrProvider implements PaymentProvider { private mapStatus(status: string): ProviderPaymentStatus { switch (status?.toUpperCase()) { - case 'SUCCESS': - case 'COMPLETED': + case "SUCCESS": + case "COMPLETED": return ProviderPaymentStatus.SUCCEEDED; - case 'FAILED': - case 'REJECTED': - case 'EXPIRED': + case "FAILED": + case "REJECTED": + case "EXPIRED": return ProviderPaymentStatus.FAILED; - case 'PENDING': + case "PENDING": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': + case "PROCESSING": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -157,21 +161,19 @@ export class CbeBirrProvider implements PaymentProvider { private signRequest(data: Record): string { const sortedKeys = Object.keys(data).sort(); - const signString = sortedKeys - .map((key) => `${key}=${data[key]}`) - .join('&'); + const signString = sortedKeys.map((key) => `${key}=${data[key]}`).join("&"); return crypto - .createHmac('sha256', this.secretKey) + .createHmac("sha256", this.secretKey) .update(signString) - .digest('hex'); + .digest("hex"); } private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json', - 'X-Merchant-Id': this.merchantId, + "Content-Type": "application/json", + "X-Merchant-Id": this.merchantId, }, timeout: 10_000, }; @@ -179,7 +181,9 @@ export class CbeBirrProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(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; } catch (err) { 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)}`, ); } 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; } @@ -199,18 +205,18 @@ export class CbeBirrProvider implements PaymentProvider { } private get baseUrl(): string { - return this.config.get('cbe.baseUrl') ?? ''; + return this.config.get("cbe.baseUrl") ?? ""; } private get merchantId(): string { - return this.config.get('cbe.merchantId') ?? ''; + return this.config.get("cbe.merchantId") ?? ""; } private get secretKey(): string { - return this.config.get('cbe.secretKey') ?? ''; + return this.config.get("cbe.secretKey") ?? ""; } private get notifyUrl(): string { - return this.config.get('cbe.notifyUrl') ?? ''; + return this.config.get("cbe.notifyUrl") ?? ""; } private get returnUrl(): string { - return this.config.get('cbe.returnUrl') ?? ''; + return this.config.get("cbe.returnUrl") ?? ""; } } diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index fa8455db5..35ca54a3b 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -56,7 +56,7 @@ export class DMoneyProvider implements PaymentProvider { constructor( private readonly config: ConfigService, private readonly http: HttpService, - ) { } + ) {} async initiate( input: ProviderInitiationInput, @@ -99,9 +99,9 @@ export class DMoneyProvider implements PaymentProvider { clientAction: response.checkoutUrl ? { type: "REDIRECT", url: response.checkoutUrl } : { - type: "REDIRECT", - url: `${this.baseUrl}/checkout/${response.orderId}`, - }, + type: "REDIRECT", + url: `${this.baseUrl}/checkout/${response.orderId}`, + }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), diff --git a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts index ee84bf611..2f8e954cf 100644 --- a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts +++ b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,10 +8,10 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as crypto from 'node:crypto'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; interface EBirrInitiateRequest { merchantCode: string; @@ -58,7 +58,9 @@ export class EBirrProvider implements PaymentProvider { private readonly http: HttpService, ) {} - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const amount = input.amountMinor / 100; const timestamp = Date.now(); @@ -70,7 +72,8 @@ export class EBirrProvider implements PaymentProvider { subject: `EDR Ticket`, body: `Order ${input.orderRef}`, 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, sign: this.signRequest({ merchantCode: this.merchantCode, @@ -85,7 +88,7 @@ export class EBirrProvider implements PaymentProvider { requestBody, ); - if (response.code !== '0000' || !response.data?.orderNo) { + if (response.code !== "0000" || !response.data?.orderNo) { throw new Error(`eBirr initiate failed: ${response.message}`); } @@ -93,7 +96,7 @@ export class EBirrProvider implements PaymentProvider { return { providerOrderId: response.data.orderNo, - clientAction: { type: 'REDIRECT', url: response.data.payUrl }, + clientAction: { type: "REDIRECT", url: response.data.payUrl }, expiresAt, rawInitiation: { request: this.sanitize(requestBody), @@ -120,7 +123,7 @@ export class EBirrProvider implements PaymentProvider { requestBody, ); - if (response.code !== '0000' || !response.data) { + if (response.code !== "0000" || !response.data) { throw new Error(`eBirr query failed: ${response.message}`); } @@ -129,20 +132,20 @@ export class EBirrProvider implements PaymentProvider { return { status: mapped, 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, }; } verifyWebhookSignature(payload: Record): boolean { const { sign, ...data } = payload; - if (!sign || typeof sign !== 'string') return false; + if (!sign || typeof sign !== "string") return false; const expectedSign = this.signRequest(data); - return crypto.timingSafeEqual( - Buffer.from(sign), - Buffer.from(expectedSign), - ); + return crypto.timingSafeEqual(Buffer.from(sign), Buffer.from(expectedSign)); } mapWebhookStatus(tradeStatus: string): ProviderPaymentStatus { @@ -151,17 +154,17 @@ export class EBirrProvider implements PaymentProvider { private mapStatus(tradeStatus: string): ProviderPaymentStatus { switch (tradeStatus?.toUpperCase()) { - case 'TRADE_SUCCESS': - case 'SUCCESS': + case "TRADE_SUCCESS": + case "SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case 'TRADE_CLOSED': - case 'TRADE_FAILED': - case 'FAILED': + case "TRADE_CLOSED": + case "TRADE_FAILED": + case "FAILED": return ProviderPaymentStatus.FAILED; - case 'WAIT_BUYER_PAY': - case 'PENDING': + case "WAIT_BUYER_PAY": + case "PENDING": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': + case "PROCESSING": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -170,21 +173,21 @@ export class EBirrProvider implements PaymentProvider { private signRequest(data: Record): string { const sortedKeys = Object.keys(data).sort(); - const signString = sortedKeys - .map((key) => `${key}=${data[key]}`) - .join('&') + `&key=${this.secretKey}`; + const signString = + sortedKeys.map((key) => `${key}=${data[key]}`).join("&") + + `&key=${this.secretKey}`; return crypto - .createHash('md5') + .createHash("md5") .update(signString) - .digest('hex') + .digest("hex") .toUpperCase(); } private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, timeout: 10_000, }; @@ -192,7 +195,9 @@ export class EBirrProvider implements PaymentProvider { const started = Date.now(); try { const res = await firstValueFrom(this.http.post(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; } catch (err) { 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)}`, ); } 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; } @@ -212,18 +219,18 @@ export class EBirrProvider implements PaymentProvider { } private get baseUrl(): string { - return this.config.get('ebirr.baseUrl') ?? ''; + return this.config.get("ebirr.baseUrl") ?? ""; } private get merchantCode(): string { - return this.config.get('ebirr.merchantCode') ?? ''; + return this.config.get("ebirr.merchantCode") ?? ""; } private get secretKey(): string { - return this.config.get('ebirr.secretKey') ?? ''; + return this.config.get("ebirr.secretKey") ?? ""; } private get notifyUrl(): string { - return this.config.get('ebirr.notifyUrl') ?? ''; + return this.config.get("ebirr.notifyUrl") ?? ""; } private get returnUrl(): string { - return this.config.get('ebirr.returnUrl') ?? ''; + return this.config.get("ebirr.returnUrl") ?? ""; } } diff --git a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts index f2fad00fe..39a0e8e90 100644 --- a/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts +++ b/packages/payment-providers/src/providers/telebirr/telebirr.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,22 +8,22 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as https from 'node:https'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as https from "node:https"; import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject, -} from './telebirr.crypto'; +} from "./telebirr.crypto"; import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse, -} from './telebirr.types'; +} from "./telebirr.types"; const TELEBIRR_HTTP_TIMEOUT_MS = 10_000; @@ -37,17 +37,21 @@ export class TelebirrProvider implements PaymentProvider { private readonly config: ConfigService, private readonly http: HttpService, ) { - const insecure = this.config.get('telebirr.insecureTls'); + const insecure = this.config.get("telebirr.insecureTls"); 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({ rejectUnauthorized: !insecure, - secureProtocol: 'TLSv1_2_method', + secureProtocol: "TLSv1_2_method", }); } - async initiate(input: ProviderInitiationInput): Promise { + async initiate( + input: ProviderInitiationInput, + ): Promise { const fabricToken = await this.applyFabricToken(); const requestBody = this.buildCreateOrderRequest(input); 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 platform = input.platform ?? 'web'; + const expiresAt = this.computeExpiresAt( + requestBody.biz_content.timeout_express, + ); + const platform = input.platform ?? "web"; const clientAction = - platform === 'mobile' + platform === "mobile" ? { - type: 'LAUNCH_APP' as const, - appId: this.merchantAppId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) }; + type: "LAUNCH_APP" as const, + appId: this.merchantAppId, + receiveCode: response.biz_content?.receiveCode, + shortCode: this.merchantCode, + } + : { type: "REDIRECT" as const, url: this.buildCheckoutUrl(prepayId) }; return { providerOrderId: prepayId, @@ -89,8 +95,8 @@ export class TelebirrProvider implements PaymentProvider { `${this.baseUrl}/payment/v1/merchant/queryOrder`, requestBody, { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, Authorization: fabricToken, }, ); @@ -104,36 +110,40 @@ export class TelebirrProvider implements PaymentProvider { status: mapped, providerTxnId, failureCode: - mapped === ProviderPaymentStatus.FAILED && tradeStatus ? tradeStatus : undefined, + mapped === ProviderPaymentStatus.FAILED && tradeStatus + ? tradeStatus + : undefined, rawResponse: response as Record, }; } mapTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus { switch (tradeStatus) { - case 'PAY_SUCCESS': + case "PAY_SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case 'PAY_FAILED': - case 'ORDER_CLOSED': + case "PAY_FAILED": + case "ORDER_CLOSED": return ProviderPaymentStatus.FAILED; - case 'WAIT_PAY': + case "WAIT_PAY": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PAYING': + case "PAYING": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - mapWebhookTradeStatus(tradeStatus: string | undefined): ProviderPaymentStatus { + mapWebhookTradeStatus( + tradeStatus: string | undefined, + ): ProviderPaymentStatus { switch (tradeStatus) { - case 'Completed': + case "Completed": return ProviderPaymentStatus.SUCCEEDED; - case 'Failure': - case 'Expired': + case "Failure": + case "Expired": return ProviderPaymentStatus.FAILED; - case 'Paying': - case 'Pending': + case "Paying": + case "Pending": return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; @@ -142,7 +152,9 @@ export class TelebirrProvider implements PaymentProvider { verifyWebhookSignature(payload: Record): boolean { 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 verifyRequestObject(payload, this.publicKey); @@ -153,12 +165,14 @@ export class TelebirrProvider implements PaymentProvider { `${this.baseUrl}/payment/v1/token`, { appSecret: this.appSecret }, { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, }, ); 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; } @@ -171,51 +185,61 @@ export class TelebirrProvider implements PaymentProvider { `${this.baseUrl}/payment/v1/inapp/createOrder`, body, { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, + "Content-Type": "application/json", + "X-APP-Key": this.fabricAppId, Authorization: fabricToken, }, ); } - private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest { + private buildCreateOrderRequest( + input: ProviderInitiationInput, + ): CreateOrderRequest { const totalAmount = String(input.amountMinor / 100); const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), - method: 'payment.preorder' as const, - version: '1.0' as const, + method: "payment.preorder" as const, + version: "1.0" as const, biz_content: { notify_url: this.notifyUrl, appid: this.merchantAppId, merch_code: this.merchantCode, merch_order_id: input.merchantOrderId, - trade_type: 'Checkout' as const, + trade_type: "Checkout" as const, title: `EDR ${input.orderRef}`, total_amount: totalAmount, trans_currency: input.currency, timeout_express: this.timeoutExpress, - redirect_url: input.redirectUrl + redirect_url: input.redirectUrl, }, }; - const sign = signRequestObject(req as unknown as Record, this.privateKey); - return { ...req, sign, sign_type: 'SHA256WithRSA' }; + const sign = signRequestObject( + req as unknown as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; } - private buildQueryOrderRequest(merchantOrderId: string): Record { + private buildQueryOrderRequest( + merchantOrderId: string, + ): Record { const req = { timestamp: createTimestamp(), nonce_str: createNonceStr(), - method: 'payment.queryorder', - version: '1.0', + method: "payment.queryorder", + version: "1.0", biz_content: { appid: this.merchantAppId, merch_code: this.merchantCode, merch_order_id: merchantOrderId, }, }; - const sign = signRequestObject(req as Record, this.privateKey); - return { ...req, sign, sign_type: 'SHA256WithRSA' }; + const sign = signRequestObject( + req as Record, + this.privateKey, + ); + return { ...req, sign, sign_type: "SHA256WithRSA" }; } private buildCheckoutUrl(prepayId: string): string { @@ -233,27 +257,34 @@ export class TelebirrProvider implements PaymentProvider { `nonce_str=${map.nonce_str}`, `prepay_id=${map.prepay_id}`, `timestamp=${map.timestamp}`, - 'sign_type=SHA256WithRSA', + "sign_type=SHA256WithRSA", `sign=${sign}`, - 'version=1.0', - 'trade_type=Checkout', - ].join('&'); + "version=1.0", + "trade_type=Checkout", + ].join("&"); return `${this.webBaseUrl}${rawRequest}`; } private computeExpiresAt(timeoutExpress: string): Date { 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); } private toMinutes(n: number, unit: string): number { switch (unit) { - case 's': return Math.max(1, Math.round(n / 60)); - case 'm': return n; - case 'h': return n * 60; - case 'd': return n * 60 * 24; - default: return 15; + case "s": + return Math.max(1, Math.round(n / 60)); + case "m": + return n; + 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(); try { const res = await firstValueFrom(this.http.post(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; } catch (err) { 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}`, ); } 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; } @@ -289,14 +324,34 @@ export class TelebirrProvider implements PaymentProvider { return rest; } - private get baseUrl(): string { return this.config.get('telebirr.baseUrl') ?? ''; } - private get webBaseUrl(): string { return this.config.get('telebirr.webBaseUrl') ?? ''; } - private get fabricAppId(): string { return this.config.get('telebirr.fabricAppId') ?? ''; } - private get appSecret(): string { return this.config.get('telebirr.appSecret') ?? ''; } - private get merchantAppId(): string { return this.config.get('telebirr.merchantAppId') ?? ''; } - private get merchantCode(): string { return this.config.get('telebirr.merchantCode') ?? ''; } - private get notifyUrl(): string { return this.config.get('telebirr.notifyUrl') ?? ''; } - private get timeoutExpress(): string { return this.config.get('telebirr.timeoutExpress') ?? '15m'; } - private get privateKey(): string { return this.config.get('telebirr.privateKey') ?? ''; } - private get publicKey(): string { return this.config.get('telebirr.publicKey') ?? ''; } + private get baseUrl(): string { + return this.config.get("telebirr.baseUrl") ?? ""; + } + private get webBaseUrl(): string { + return this.config.get("telebirr.webBaseUrl") ?? ""; + } + private get fabricAppId(): string { + return this.config.get("telebirr.fabricAppId") ?? ""; + } + private get appSecret(): string { + return this.config.get("telebirr.appSecret") ?? ""; + } + private get merchantAppId(): string { + return this.config.get("telebirr.merchantAppId") ?? ""; + } + private get merchantCode(): string { + return this.config.get("telebirr.merchantCode") ?? ""; + } + private get notifyUrl(): string { + return this.config.get("telebirr.notifyUrl") ?? ""; + } + private get timeoutExpress(): string { + return this.config.get("telebirr.timeoutExpress") ?? "15m"; + } + private get privateKey(): string { + return this.config.get("telebirr.privateKey") ?? ""; + } + private get publicKey(): string { + return this.config.get("telebirr.publicKey") ?? ""; + } } diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index ea16eccb5..276ff2de2 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -1,6 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; import { PaymentProvider, ProviderInitiationInput, @@ -8,111 +8,70 @@ import { ProviderStatus, ProviderPaymentStatus, ProviderMethod, -} from '@edr/types'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; +} from "@edr/types"; +import { AxiosError, AxiosRequestConfig } from "axios"; +import { firstValueFrom } from "rxjs"; +import * as crypto from "node:crypto"; +import * as https from "node:https"; +import { + WaafiGetTranInfoRequest, + WaafiGetTranInfoResponse, + WaafiHppPurchaseRequest, + WaafiHppPurchaseResponse, +} from "./waafi.types"; const WAAFI_HTTP_TIMEOUT_MS = 10_000; - -interface WaafiInitiateRequest { - schemaVersion: string; - requestId: string; - timestamp: string; - channelName: string; - serviceName: string; - serviceParams: { - merchantUid: string; - apiUserId: string; - apiKey: string; - paymentMethod: string; - payerInfo: { - accountNo: string; - }; - transactionInfo: { - referenceId: string; - invoiceId: string; - amount: number; - currency: string; - description: string; - }; - }; -} - -interface WaafiInitiateResponse { - responseCode: string; - responseMsg: string; - params?: { - state: string; - referenceId: string; - transactionId: string; - checkoutUrl?: string; - }; -} - -interface WaafiQueryRequest { - schemaVersion: string; - requestId: string; - timestamp: string; - channelName: string; - serviceName: string; - serviceParams: { - merchantUid: string; - apiUserId: string; - apiKey: string; - transactionId?: string; - referenceId?: string; - }; -} - -interface WaafiQueryResponse { - responseCode: string; - responseMsg: string; - params?: { - state: string; - referenceId: string; - transactionId: string; - amount: number; - currency: string; - paidAmount?: number; - }; -} +const WAAFI_SUCCESS_CODE = "2001"; +/** Waafi cancels an unprocessed HPP session after ~5 minutes (RCS_HPP_USERACTION_TIMEOUT). */ +const WAAFI_HPP_SESSION_MS = 5 * 60_000; @Injectable() export class WaafiProvider implements PaymentProvider { readonly method = ProviderMethod.WAAFI; private readonly logger = new Logger(WaafiProvider.name); + private readonly httpsAgent: https.Agent; constructor( private readonly config: ConfigService, private readonly http: HttpService, - ) {} + ) { + const insecure = this.config.get("waafi.insecureTls"); + if (insecure) { + this.logger.warn( + "WAAFI_INSECURE_TLS=true — TLS verification disabled for Waafi calls. DEV ONLY.", + ); + } + this.httpsAgent = new https.Agent({ rejectUnauthorized: !insecure }); + } - async initiate(input: ProviderInitiationInput): Promise { - const requestBody = this.buildInitiateRequest(input); - const response = await this.postJson( + async initiate( + input: ProviderInitiationInput, + ): Promise { + const requestBody = this.buildPurchaseRequest(input); + const response = await this.postJson( `${this.baseUrl}/asm`, requestBody, ); - if (response.responseCode !== '2001') { + if (response.responseCode !== WAAFI_SUCCESS_CODE) { throw new Error( - `Waafi initiate failed: ${response.responseCode} - ${response.responseMsg}`, + `Waafi HPP_PURCHASE failed: responseCode=${response.responseCode} errorCode=${response.errorCode} msg=${response.responseMsg}`, ); } - const transactionId = response.params?.transactionId; - const checkoutUrl = response.params?.checkoutUrl || `${this.baseUrl}/checkout?ref=${transactionId}`; - - if (!transactionId) { - throw new Error(`Waafi returned no transactionId: ${JSON.stringify(response)}`); + const checkoutUrl = + response.params?.hppUrl ?? response.params?.directPaymentLink; + const orderId = response.params?.orderId; + if (!checkoutUrl || !orderId) { + throw new Error( + `Waafi HPP_PURCHASE succeeded but returned no hppUrl/orderId: ${JSON.stringify(response)}`, + ); } - const expiresAt = new Date(Date.now() + 15 * 60_000); // 15 minutes - return { - providerOrderId: transactionId, - clientAction: { type: 'REDIRECT', url: checkoutUrl }, - expiresAt, + providerOrderId: orderId, + clientAction: { type: "REDIRECT", url: checkoutUrl }, + expiresAt: new Date(Date.now() + WAAFI_HPP_SESSION_MS), rawInitiation: { request: this.sanitize(requestBody), response, @@ -121,114 +80,159 @@ export class WaafiProvider implements PaymentProvider { } async queryStatus(merchantOrderId: string): Promise { - const requestBody = this.buildQueryRequest(merchantOrderId); - const response = await this.postJson( + const requestBody = this.buildGetTranInfoRequest(merchantOrderId); + const response = await this.postJson( `${this.baseUrl}/asm`, requestBody, ); - const state = response.params?.state; + const rawState = response.params?.status ?? response.params?.tranStatusDesc; const transactionId = response.params?.transactionId; - const mapped = this.mapState(state); + const mapped = this.mapStatus(rawState); return { status: mapped, providerTxnId: transactionId, - failureCode: mapped === ProviderPaymentStatus.FAILED && state ? state : undefined, + failureCode: + mapped === ProviderPaymentStatus.FAILED && rawState + ? rawState + : undefined, rawResponse: response as unknown as Record, }; } - mapState(state: string | undefined): ProviderPaymentStatus { - switch (state) { - case 'APPROVED': - case 'SUCCESS': + /** Map a webhook `payment.status` to the shared status enum. */ + mapWebhookStatus(status: string | undefined): ProviderPaymentStatus { + return this.mapStatus(status); + } + + /** + * Verify an HMAC-SHA256 webhook signature. + * + * Signing string is `{timestamp}.{eventId}.{rawBody}` over the *raw* request body bytes — the + * caller must pass the unparsed body string. Returns false (never throws) on any mismatch so + * callers can treat verification as a boolean gate. + */ + verifyWebhookSignature( + rawBody: string, + signature: string | undefined, + timestamp: string | undefined, + eventId: string | undefined, + ): boolean { + if (!this.webhookSecret) { + this.logger.error( + "WAAFI_WEBHOOK_SECRET not configured; rejecting all webhooks", + ); + return false; + } + if (!signature || !timestamp || !eventId) { + this.logger.warn( + "Waafi webhook missing signature/timestamp/event-id headers", + ); + return false; + } + + const signingString = `${timestamp}.${eventId}.${rawBody}`; + const expected = crypto + .createHmac("sha256", this.webhookSecret) + .update(signingString) + .digest("hex"); + + const provided = Buffer.from(signature, "utf8"); + const computed = Buffer.from(expected, "utf8"); + if (provided.length !== computed.length) return false; + return crypto.timingSafeEqual(provided, computed); + } + + private mapStatus(raw: string | undefined): ProviderPaymentStatus { + switch (raw?.toUpperCase()) { + case "APPROVED": + case "SUCCESS": return ProviderPaymentStatus.SUCCEEDED; - case 'FAILED': - case 'DECLINED': - case 'CANCELLED': - case 'EXPIRED': + case "CANCELED": + case "CANCELLED": + return ProviderPaymentStatus.CANCELLED; + case "DECLINED": + case "FAILED": + case "EXPIRED": + case "TIMEOUT": return ProviderPaymentStatus.FAILED; - case 'PENDING': - case 'INITIATED': + case "PENDING": + case "INITIATED": return ProviderPaymentStatus.REQUIRES_ACTION; - case 'PROCESSING': - return ProviderPaymentStatus.PROCESSING; default: return ProviderPaymentStatus.PROCESSING; } } - verifyWebhookSignature(payload: Record): boolean { - // Waafi webhook signature verification - // Implementation depends on Waafi's webhook signature mechanism - const signature = payload.signature as string; - const apiKey = this.apiKey; - - if (!signature || !apiKey) { - this.logger.error('Waafi webhook missing signature or API key not configured'); - return false; - } - - // TODO: Implement actual signature verification based on Waafi documentation - // For now, basic validation - return signature.length > 0; - } - - private buildInitiateRequest(input: ProviderInitiationInput): WaafiInitiateRequest { - const amount = input.amountMinor / 100; // Convert minor units to major - + private buildPurchaseRequest( + input: ProviderInitiationInput, + ): WaafiHppPurchaseRequest { return { - schemaVersion: '1.0', - requestId: this.generateRequestId(), - timestamp: new Date().toISOString(), - channelName: 'WEB', - serviceName: 'API_PURCHASE', + schemaVersion: "1.0", + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), + channelName: "WEB", + serviceName: "HPP_PURCHASE", serviceParams: { merchantUid: this.merchantUid, - apiUserId: this.apiUserId, - apiKey: this.apiKey, - paymentMethod: 'MWALLET_ACCOUNT', - payerInfo: { - accountNo: 'CUSTOMER', // Customer enters their number on Waafi page - }, + storeId: this.storeId, + hppKey: this.hppKey, + paymentMethod: this.paymentMethod, + // Browser bounce-back is per-transaction (each calling app has its own UI), so the + // caller-supplied URLs win; the static config is only a fallback. UX-only — the + // webhook remains the single source of truth for payment state. + hppSuccessCallbackUrl: input.returnUrl ?? this.successUrl, + hppFailureCallbackUrl: input.failureUrl ?? this.failureUrl, + hppRespDataFormat: this.respDataFormat, + // MWALLET_ACCOUNT requires the payer phone up front; omit if the caller did not supply it + // and let the hosted page collect it. See docs/waffi open question on payer-phone sourcing. + ...(input.payerAccount + ? { payerInfo: { subscriptionId: input.payerAccount } } + : {}), transactionInfo: { referenceId: input.merchantOrderId, - invoiceId: input.orderRef, - amount, - currency: input.currency === 'ETB' ? 'DJF' : input.currency, // Convert ETB to DJF + amount: this.toAmount(input.amountMinor), + // Waafi has no ETB; `waafi.currency` overrides the booking currency when set. + currency: this.currency || input.currency, description: `EDR ${input.orderRef}`, }, }, }; } - private buildQueryRequest(merchantOrderId: string): WaafiQueryRequest { + private buildGetTranInfoRequest( + merchantOrderId: string, + ): WaafiGetTranInfoRequest { return { - schemaVersion: '1.0', - requestId: this.generateRequestId(), - timestamp: new Date().toISOString(), - channelName: 'WEB', - serviceName: 'API_QUERY', + schemaVersion: "1.0", + requestId: crypto.randomUUID(), + timestamp: this.timestamp(), + channelName: "WEB", + serviceName: "HPP_GETTRANINFO", serviceParams: { merchantUid: this.merchantUid, - apiUserId: this.apiUserId, - apiKey: this.apiKey, + storeId: this.storeId, + hppKey: this.hppKey, referenceId: merchantOrderId, }, }; } - private generateRequestId(): string { - return `EDR-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + /** Convert integer minor units to a 2-decimal major amount (truncated, never rounded up). */ + private toAmount(amountMinor: number): number { + return Math.trunc(amountMinor) / 100; + } + + private timestamp(): string { + return Math.round(Date.now() / 1000).toString(); } private async postJson(url: string, body: unknown): Promise { const config: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/json', - }, + headers: { "Content-Type": "application/json" }, timeout: WAAFI_HTTP_TIMEOUT_MS, + httpsAgent: this.httpsAgent, }; const started = Date.now(); @@ -252,24 +256,43 @@ export class WaafiProvider implements PaymentProvider { } } - private sanitize(body: WaafiInitiateRequest): Record { - const sanitized = { ...body }; - if (sanitized.serviceParams?.apiKey) { - sanitized.serviceParams.apiKey = '***REDACTED***'; - } - return sanitized as unknown as Record; + private sanitize(body: WaafiHppPurchaseRequest): Record { + return { + ...body, + serviceParams: { ...body.serviceParams, hppKey: "***REDACTED***" }, + }; } private get baseUrl(): string { - return this.config.get('waafi.baseUrl') ?? 'https://api.waafipay.net'; + return ( + this.config.get("waafi.baseUrl") ?? "https://sandbox.waafipay.net" + ); } private get merchantUid(): string { - return this.config.get('waafi.merchantUid') ?? ''; + return this.config.get("waafi.merchantUid") ?? ""; } - private get apiUserId(): string { - return this.config.get('waafi.apiUserId') ?? ''; + private get storeId(): string { + return this.config.get("waafi.storeId") ?? ""; } - private get apiKey(): string { - return this.config.get('waafi.apiKey') ?? ''; + private get hppKey(): string { + return this.config.get("waafi.hppKey") ?? ""; + } + private get webhookSecret(): string { + return this.config.get("waafi.webhookSecret") ?? ""; + } + private get paymentMethod(): string { + return this.config.get("waafi.paymentMethod") ?? "MWALLET_ACCOUNT"; + } + private get currency(): string { + return this.config.get("waafi.currency") ?? ""; + } + private get successUrl(): string { + return this.config.get("waafi.successUrl") ?? ""; + } + private get failureUrl(): string { + return this.config.get("waafi.failureUrl") ?? ""; + } + private get respDataFormat(): number { + return this.config.get("waafi.respDataFormat") ?? 1; } } diff --git a/packages/payment-providers/src/providers/waafi/waafi.types.ts b/packages/payment-providers/src/providers/waafi/waafi.types.ts new file mode 100644 index 000000000..3f3c6f3cf --- /dev/null +++ b/packages/payment-providers/src/providers/waafi/waafi.types.ts @@ -0,0 +1,103 @@ +/** + * WaafiPay (Hosted Payment Page) request/response types. + * + * WaafiPay multiplexes every operation through a single `POST /asm` endpoint, discriminated by + * `serviceName`. We use the HPP family (`HPP_PURCHASE`, `HPP_GETTRANINFO`) which returns a hosted + * redirect URL and supports webhooks — see docs/waffi/intro.md. + */ + +/** Terminal/intermediate transaction states reported by Waafi (sync `state` / `HPP_GETTRANINFO`). */ +export type WaafiState = + | 'APPROVED' + | 'DECLINED' + | 'FAILED' + | 'CANCELED' + | 'EXPIRED' + | 'TIMEOUT' + | string; + +/** Common request envelope shared by every `/asm` call. */ +export interface WaafiRequestEnvelope { + schemaVersion: '1.0'; + requestId: string; + timestamp: string; + channelName: 'WEB'; + serviceName: string; + serviceParams: TServiceParams; +} + +/** Common response envelope. `responseCode === '2001'` means the request was processed (not paid). */ +export interface WaafiResponseEnvelope { + schemaVersion: string; + timestamp: string; + responseId: string; + responseCode: string; + errorCode: string; + responseMsg: string; + params?: TParams; +} + +// --- HPP_PURCHASE ----------------------------------------------------------------------------- + +export interface WaafiHppPurchaseServiceParams { + merchantUid: string; + storeId: string; + hppKey: string; + paymentMethod: string; + hppSuccessCallbackUrl: string; + hppFailureCallbackUrl: string; + /** Callback data format: 1 = POST, 2 = GET, 4 = Result Token. */ + hppRespDataFormat: number; + /** Required for MWALLET_ACCOUNT — pre-fills (and locks) the payer's phone on the hosted page. */ + payerInfo?: { + subscriptionId: string; + }; + transactionInfo: { + referenceId: string; + amount: number; + currency: string; + description?: string; + }; +} + +export type WaafiHppPurchaseRequest = WaafiRequestEnvelope; + +export interface WaafiHppPurchaseParams { + hppUrl: string; + directPaymentLink?: string; + orderId: string; + referenceId: string; +} + +export type WaafiHppPurchaseResponse = WaafiResponseEnvelope; + +// --- HPP_GETTRANINFO -------------------------------------------------------------------------- + +export interface WaafiGetTranInfoServiceParams { + merchantUid: string; + storeId: string; + hppKey: string; + /** Either the merchant referenceId or the Waafi transactionId may be supplied. */ + referenceId?: string; + transactionId?: string; +} + +export type WaafiGetTranInfoRequest = WaafiRequestEnvelope; + +export interface WaafiGetTranInfoParams { + tranStatusDesc?: string; + amount?: string; + payerId?: string; + paymentMethod?: string; + description?: string; + tranDate?: string; + currency?: string; + invoiceId?: string; + referenceId?: string; + tranAmount?: string; + transactionId?: string; + tranStatusId?: string; + status?: WaafiState; +} + +export type WaafiGetTranInfoResponse = WaafiResponseEnvelope; diff --git a/packages/payment-providers/src/webhooks/waafi-webhook.types.ts b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts index 247c46d40..658b293cd 100644 --- a/packages/payment-providers/src/webhooks/waafi-webhook.types.ts +++ b/packages/payment-providers/src/webhooks/waafi-webhook.types.ts @@ -1,15 +1,66 @@ -export interface WaafiWebhookPayload { - schemaVersion: string; - requestId: string; - timestamp: string; - eventType: string; - params: { - state: string; - referenceId: string; - transactionId: string; - amount: number; - currency: string; - description?: string; - }; - signature?: string; +/** + * WaafiPay webhook payloads (HPP authorization / refund / test). + * + * Webhooks are HMAC-SHA256 signed over `{timestamp}.{event_id}.{raw_body}` except `webhook.test`, + * which is unsigned and only sent to validate endpoint reachability. See docs/waffi/intro.md. + */ + +export type WaafiWebhookEvent = 'authorization' | 'refund' | 'webhook.test'; + +export type WaafiWebhookStatus = + | 'APPROVED' + | 'FAILED' + | 'DECLINED' + | 'CANCELED' + | 'EXPIRED' + | 'TIMEOUT' + | string; + +/** Headers Waafi sends alongside signed webhooks (lowercased, as exposed by NestJS). */ +export interface WaafiWebhookHeaders { + 'x-webhook-timestamp'?: string; + 'x-webhook-event-id'?: string; + 'x-webhook-signature'?: string; + 'x-webhook-signature-alg'?: string; } + +/** Nested payment object present on `authorization` and `refund` events. */ +export interface WaafiWebhookPayment { + transaction_id: string; + /** Present on authorization events (optional). */ + order_id?: string; + transfer_code: string; + amount: number; + currency: string; + /** Present on authorization events. */ + payment_method?: string; + status: WaafiWebhookStatus; + /** Our merchantOrderId. */ + reference_id: string; + /** Present on authorization events. */ + channel?: string; + description?: string; + date: string; +} + +/** Unsigned validation ping sent on webhook registration/update. */ +export interface WaafiWebhookTestPayload { + event: 'webhook.test'; + message?: string; + merchant_uid: string; +} + +/** Real transaction notification (authorization or refund). */ +export interface WaafiWebhookTransactionPayload { + event: 'authorization' | 'refund'; + merchant_id: number; + merchant_uid: string; + user_id: string; + /** Authorization only. */ + customer_identity?: string; + /** Authorization only (optional). */ + cardholder_name?: string; + payment: WaafiWebhookPayment; +} + +export type WaafiWebhookPayload = WaafiWebhookTestPayload | WaafiWebhookTransactionPayload; diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 66a6dead3..02930d151 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -43,8 +43,17 @@ export interface ProviderInitiationInput { amountMinor: number; currency: string; platform?: PaymentPlatform; - returnUrl?: string - redirectUrl?: string + /** + * Payer account identifier (e.g. mobile-wallet MSISDN in full international format). + * Optional and provider-specific: some wallet providers (e.g. Waafi HPP with + * MWALLET_ACCOUNT) require the payer's phone number up front to pre-fill the hosted page. + */ + payerAccount?: string; + /** Optional caller-supplied redirect targets for redirect/HPP-style providers. */ + returnUrl?: string; + redirectUrl?: string; + /** Where the browser lands when the hosted page fails/cancels (UX only — never trusted). */ + failureUrl?: string; } export interface ProviderInitiationResult { @@ -67,3 +76,103 @@ export interface PaymentProvider { initiate(input: ProviderInitiationInput): Promise; queryStatus(merchantOrderId: string): Promise; } + +/* ------------------------------------------------------------------------------------------------ + * Payment microservice contracts (docs/payment-service) + * + * Shared shapes exchanged between the payment microservice (apps/edr-payment-api) and the + * domain apps (passenger/freight). Both sides import these so the wire format cannot drift. + * ---------------------------------------------------------------------------------------------- */ + +/** Which domain app owns the order being paid for. Routing discriminator on every intent. */ +export enum PaymentService { + PASSENGER = "PASSENGER", + FREIGHT = "FREIGHT", +} + +/** What kind of domain order the intent references (soft reference — never a cross-schema FK). */ +export enum PaymentReferenceType { + BOOKING = "BOOKING", + SHIPMENT = "SHIPMENT", +} + + +/** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */ +export interface InitiatePaymentRequest { + service: PaymentService; + referenceType: PaymentReferenceType; + /** Domain order id (booking/shipment id). Soft reference; the app has already validated it. */ + referenceId: string; + /** Human-readable order ref (e.g. booking ref) shown on provider pages. Defaults to referenceId. */ + orderRef?: string; + /** App-asserted authoritative amount in minor units (computed server-side by the domain app). */ + amountMinor: number; + currency: string; + provider: ProviderMethod; + platform?: PaymentPlatform; + payerAccount?: string; + /** + * Where the provider's hosted page sends the BROWSER back after success — each calling app + * passes its own UI URL (passenger portal vs freight portal). Per-transaction and UX-only: + * the redirect never confirms payment (only the webhook / status query does), so per-app + * values are safe even though the server-to-server webhook URL is one per merchant. + * Falls back to the payment service's provider config when omitted. + */ + returnUrl?: string; + /** Failure/cancel counterpart of returnUrl. */ + failureUrl?: string; + /** Optional caller key to dedupe retried initiations beyond the per-reference upsert. */ + idempotencyKey?: string; +} + +/** Response of `POST /payments/initiate` and shape of intent lookups. */ +export interface PaymentIntentSnapshot { + intentId: string; + service: PaymentService; + referenceType: PaymentReferenceType; + referenceId: string; + merchantOrderId: string; + provider: ProviderMethod; + status: ProviderPaymentStatus; + amountMinor: number; + currency: string; + clientAction?: ClientAction; + providerTxnId?: string; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + expiresAt?: string; +} + +export type PaymentEventType = "payment.succeeded" | "payment.failed"; + +/** Versioned envelope delivered (at-least-once) to the owning app's mark-paid consumer. */ +interface PaymentEventBase { + version: 1; + /** Outbox row id — stable across redeliveries; consumers may use it as a dedupe key. */ + eventId: string; + eventType: PaymentEventType; + occurredAt: string; + service: PaymentService; + intentId: string; + referenceType: PaymentReferenceType; + referenceId: string; + merchantOrderId: string; + provider: ProviderMethod; + amountMinor: number; + currency: string; +} + +export interface PaymentSucceededEvent extends PaymentEventBase { + eventType: "payment.succeeded"; + providerTxnId?: string; + paidAt: string; +} + +export interface PaymentFailedEvent extends PaymentEventBase { + eventType: "payment.failed"; + failureCode?: string; + failureMessage?: string; +} + +export type PaymentEvent = PaymentSucceededEvent | PaymentFailedEvent; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b91c2013..b6771011e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,9 +392,6 @@ importers: apps/edr-passenger-api: dependencies: - '@edr/payment-providers': - specifier: workspace:* - version: link:../../packages/payment-providers '@edr/types': specifier: workspace:* version: link:../../packages/types @@ -672,6 +669,112 @@ importers: specifier: ^5.5.4 version: 5.9.3 + apps/edr-payment-api: + dependencies: + '@edr/api-common': + specifier: workspace:* + version: link:../../packages/api-common + '@edr/payment-providers': + specifier: workspace:* + version: link:../../packages/payment-providers + '@edr/types': + specifier: workspace:* + version: link:../../packages/types + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.0 + version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/schedule': + specifier: ^6.0.0 + version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/swagger': + specifier: ^11.4.2 + version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/typeorm': + specifier: ^11.0.1 + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + axios: + specifier: ^1.16.1 + version: 1.17.0 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + pg: + specifier: ^8.13.0 + version: 8.21.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typeorm: + specifier: 0.3.30 + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.21(@types/node@20.19.42)(prettier@3.8.3) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/jest': + specifier: ^29.5.13 + version: 29.5.14 + '@types/node': + specifier: ^20.14.0 + version: 20.19.42 + '@types/pg': + specifier: ^8.6.7 + version: 8.20.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + ts-jest: + specifier: ^29.2.5 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) + ts-loader: + specifier: ^9.5.1 + version: 9.6.0(loader-utils@1.4.2)(typescript@5.9.3)(webpack@5.106.0) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + packages/api-common: dependencies: '@edr/types': diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index ca3ad1cc9..802793b6e 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -30,6 +30,7 @@ declare -A SERVICE_ENV_TARGET=( ["passenger-api"]="apps/edr-passenger-api/.env" ["passenger-portal"]="apps/edr-passenger-web/portal/.env" ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" + ["payment-api"]="apps/edr-payment-api/.env" ) for service in "$@"; do