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