billing frontend for mariadb. setup as otb_billing for outsidethebox.top accounting. also involved with outsidethedb
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

21 lines
26 KiB

<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Invoice Detail - OutsideTheBox</title> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/brand.css"><style> .portal-wrap { max-width: 1100px; margin: 2rem auto; padding: 1.25rem; } .portal-top { display:flex; justify-content:space-between; align-items:center; gap:1rem; flex-wrap:wrap; margin-bottom: 1rem; } .portal-actions a { margin-left: 0.75rem; text-decoration: underline; } .detail-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin: 1rem 0 1.25rem 0; } .detail-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-bottom: 1rem; } .detail-card h3 { margin-top: 0; margin-bottom: 0.4rem; } table.portal-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } table.portal-table th, table.portal-table td { padding: 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; } table.portal-table th { background: #e9eef7; color: #10203f; } .status-badge { display: inline-block; padding: 0.18rem 0.55rem; border-radius: 999px; font-size: 0.86rem; font-weight: 700; } .status-paid { background: rgba(34, 197, 94, 0.18); color: #4ade80; } .status-pending { background: rgba(245, 158, 11, 0.20); color: #fbbf24; } .status-overdue { background: rgba(239, 68, 68, 0.18); color: #f87171; } .status-other { background: rgba(148, 163, 184, 0.20); color: #cbd5e1; } .pay-card { border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.03); margin-top: 1.25rem; } .pay-selector-row { display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; margin-top:0.75rem; } .pay-selector { padding: 10px 12px; min-width: 220px; border-radius: 8px; } .pay-panel { margin-top: 1rem; padding: 1rem; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(255,255,255,0.02); } .pay-panel.hidden { display: none; } .pay-btn { display:inline-block; padding:12px 18px; color:#ffffff; text-decoration:none; border-radius:8px; font-weight:700; border:none; cursor:pointer; margin:8px 0 0 0; } .pay-btn-square { background:#16a34a; } .pay-btn-wallet { background:#2563eb; } .pay-btn-mobile { background:#7c3aed; } .pay-btn-copy { background:#374151; } .error-box { border: 1px solid rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); color: #fecaca; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } .success-box { border: 1px solid rgba(34, 197, 94, 0.55); background: rgba(22, 101, 52, 0.18); color: #dcfce7; border-radius: 10px; padding: 12px 14px; margin-bottom: 1rem; } .snapshot-wrap { position: relative; margin-top: 1rem; border: 1px solid rgba(255,255,255,0.14); border-radius: 14px; padding: 1rem; background: rgba(255,255,255,0.02); } .snapshot-header { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; } .snapshot-meta { flex: 1 1 auto; min-width: 0; line-height: 1.65; } .snapshot-timer-box { width: 220px; min-height: 132px; border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; background: rgba(0,0,0,0.18); display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; padding: 0.9rem; } .snapshot-timer-value { font-size: 2rem; font-weight: 800; line-height: 1.1; } .snapshot-timer-label { margin-top: 0.55rem; font-size: 0.95rem; opacity: 0.95; } .snapshot-timer-expired { color: #f87171; } .quote-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } .quote-table th, .quote-table td { padding: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.12); text-align: left; vertical-align: top; } .quote-table th { background: #e9eef7; color: #10203f; } .quote-badge { display: inline-block; padding: 0.14rem 0.48rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; margin-left: 0.4rem; } .quote-live { background: rgba(34, 197, 94, 0.18); color: #4ade80; } .quote-stale { background: rgba(239, 68, 68, 0.18); color: #f87171; } .quote-pick-btn { padding: 8px 12px; border-radius: 8px; border: none; background: #2563eb; color: #fff; font-weight: 700; cursor: pointer; } .quote-pick-btn[disabled] { opacity: 0.5; cursor: not-allowed; } .lock-box { margin-top: 1rem; border: 1px solid rgba(34, 197, 94, 0.28); background: rgba(22, 101, 52, 0.16); border-radius: 12px; padding: 1rem; } .lock-box.expired { border-color: rgba(239, 68, 68, 0.55); background: rgba(127, 29, 29, 0.22); } .lock-grid { display:grid; grid-template-columns: 1fr 220px; gap:1rem; align-items:start; } .lock-code { display:block; margin-top:0.35rem; padding:0.65rem 0.8rem; background: rgba(0,0,0,0.22); border-radius: 8px; overflow-wrap:anywhere; } .wallet-actions { display:flex; gap:0.75rem; flex-wrap:wrap; margin-top:0.9rem; align-items:center; } .wallet-help { margin-top: 0.85rem; padding: 0.9rem 1rem; border-radius: 10px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.10); } .wallet-help h4 { margin: 0 0 0.55rem 0; font-size: 1rem; } .wallet-help p { margin: 0.35rem 0; } .wallet-note { opacity:0.9; margin-top:0.65rem; } .mono { font-family: monospace; } .copy-row { display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center; margin-top:0.65rem; } .copy-target { flex: 1 1 420px; min-width: 220px; } .copy-status { display:inline-block; margin-left: 0.5rem; opacity: 0.9; } @media (max-width: 820px) { .snapshot-header, .lock-grid { grid-template-columns: 1fr; display:block; } .snapshot-timer-box { width: 100%; margin-top: 1rem; min-height: 110px; } } </style> <link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
{% include "includes/site_nav.html" %} <div style="background:#111827;padding:10px 20px;"> </div> <div class="portal-wrap"> <div class="portal-top"> <div> <h1>Invoice Detail</h1> <p>{{ client.company_name or client.contact_name or client.email }}</p> </div> <div class="portal-actions"> <a href="/portal/dashboard">Back to Dashboard</a> <a href="mailto:support@outsidethebox.top?subject=Portal%20Support" href="mailto:support@outsidethebox.top">Customer Support</a> <a href="/portal/logout">Logout</a> </div> </div> {% if (invoice.status or "")|lower == "paid" %} <div class="success-box">✓ This invoice has been paid. Thank you!</div> {% endif %} {% if crypto_error %} <div class="error-box">{{ crypto_error }}</div> {% endif %} <div class="detail-grid"> <div class="detail-card"><h3>Invoice</h3><div>{{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</div></div> <div class="detail-card"> <h3>Status</h3> {% set s = (invoice.status or "")|lower %} {% if pending_crypto_payment and pending_crypto_payment.txid and not pending_crypto_payment.processing_expired and s != "paid" %} <span class="status-badge status-pending">processing</span> {% elif s == "paid" %}<span class="status-badge status-paid">{{ invoice.status }}</span> {% elif s == "pending" %}<span class="status-badge status-pending">{{ invoice.status }}</span> {% elif s == "overdue" %}<span class="status-badge status-overdue">{{ invoice.status }}</span> {% else %}<span class="status-badge status-other">{{ invoice.status }}</span>{% endif %} </div> <div class="detail-card"><h3>Created</h3><div>{{ invoice.created_at }}</div></div> <div class="detail-card"><h3>Total</h3><div>{{ invoice.total_amount }}</div></div> <div class="detail-card"><h3>Paid</h3><div>{{ invoice.amount_paid }}</div></div> <div class="detail-card"><h3>Outstanding</h3><div>{{ invoice.outstanding }}</div></div> </div> <h2>Invoice Items</h2> <table class="portal-table"> <thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Line Total</th></tr></thead> <tbody> {% for item in items %} <tr><td>{{ item.description }}</td><td>{{ item.quantity }}</td><td>{{ item.unit_price }}</td><td>{{ item.line_total }}</td></tr> {% else %} <tr><td colspan="4">No invoice line items found.</td></tr> {% endfor %} </tbody> </table> {% if (invoice.status or "")|lower != "paid" and invoice.outstanding != "0.00" %} <div class="pay-card"> <h3>Pay Now</h3> <div class="pay-selector-row"> <label for="payMethodSelect"><strong>Choose payment method:</strong></label> <select id="payMethodSelect" class="pay-selector"> <option value="" {% if not pay_mode %}selected{% endif %}>Select…</option> <option value="etransfer" {% if pay_mode == "etransfer" %}selected{% endif %}>e-Transfer</option> <option value="square" {% if pay_mode == "square" %}selected{% endif %}>Credit Card</option> <option value="crypto" {% if pay_mode == "crypto" %}selected{% endif %}>Crypto</option> </select> </div> <div id="panel-etransfer" class="pay-panel{% if pay_mode != 'etransfer' %} hidden{% endif %}"> <p><strong>Interac e-Transfer</strong><br>Send payment to:<br>payment@outsidethebox.top<br>Reference: Invoice {{ invoice.invoice_number or ("INV-" ~ invoice.id) }}</p> </div> <div id="panel-square" class="pay-panel{% if pay_mode != 'square' %} hidden{% endif %}"> <p><strong>Credit Card (Square)</strong></p> <a href="/portal/invoice/{{ invoice.id }}/pay-square" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-square">Pay with Credit Card</a> </div> <div id="panel-crypto" class="pay-panel{% if pay_mode != 'crypto' %} hidden{% endif %}"> {% if invoice.oracle_quote and invoice.oracle_quote.quotes and crypto_options %} <div class="snapshot-wrap"> <div class="snapshot-header"> <div class="snapshot-meta"> <h3 style="margin-top:0;">Crypto Quote Snapshot</h3> <div><strong>Quoted At:</strong> {{ invoice.oracle_quote.quoted_at or "—" }}</div> <div><strong>Source Status:</strong> {{ invoice.oracle_quote.source_status or "—" }}</div> <div><strong>Frozen Amount:</strong> {{ invoice.oracle_quote.amount or invoice.quote_fiat_amount or invoice.total_amount }} {{ invoice.oracle_quote.fiat or invoice.quote_fiat_currency or "CAD" }}</div> {% if pending_crypto_payment %} <div style="margin-top:0.75rem;"><strong>Your quote is protected after acceptance.</strong></div> {% else %} <div style="margin-top:0.75rem;"><strong>Select a crypto asset to accept the quote.</strong></div> {% endif %} </div> {% if pending_crypto_payment and pending_crypto_payment.txid %} <div class="snapshot-timer-box"> <div id="processingTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div> <div id="processingTimerLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div> </div> {% elif pending_crypto_payment %} <div class="snapshot-timer-box"> <div id="lockTimerValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div> <div id="lockTimerLabel" class="snapshot-timer-label">Quote protected while you open wallet</div> </div> {% else %} <div class="snapshot-timer-box"> <div id="quoteTimerValue" class="snapshot-timer-value" data-expiry="{{ crypto_quote_window_expires_iso }}">--:--</div> <div id="quoteTimerLabel" class="snapshot-timer-label">This price times out:</div> </div> {% endif %} </div> {% if pending_crypto_payment and selected_crypto_option %} <div id="lockBox" class="lock-box{% if pending_crypto_payment.lock_expired or pending_crypto_payment.processing_expired %} expired{% endif %}"> <div class="lock-grid"> <div> <h3 style="margin-top:0;">{{ selected_crypto_option.label }} Payment Instructions</h3> <div><strong>Send exactly:</strong> {{ pending_crypto_payment.payment_amount }} {{ pending_crypto_payment.payment_currency }}</div> <div style="margin-top:0.65rem;"><strong>Destination wallet:</strong></div> <code id="walletAddressText" class="lock-code copy-target">{{ pending_crypto_payment.wallet_address }}</code> <div style="margin-top:0.65rem;"><strong>Reference / Invoice:</strong></div> <code id="invoiceRefText" class="lock-code copy-target">{{ pending_crypto_payment.reference }}</code> {% if selected_crypto_option.wallet_capable and not pending_crypto_payment.txid and not pending_crypto_payment.lock_expired %} <div class="wallet-actions"> <button type="button" id="walletPayButton" class="pay-btn pay-btn-wallet" data-invoice-id="{{ invoice.id }}" data-payment-id="{{ pending_crypto_payment.id }}" data-asset="{{ selected_crypto_option.symbol }}" data-chain-id="{{ selected_crypto_option.chain_id }}" data-asset-type="{{ selected_crypto_option.asset_type }}" data-to="{{ selected_crypto_option.wallet_address }}" data-amount="{{ pending_crypto_payment.payment_amount }}" data-decimals="{{ selected_crypto_option.decimals }}" data-token-contract="{{ selected_crypto_option.token_contract or '' }}" data-chain-add='{{ (selected_crypto_option.chain_add_params or {})|tojson|safe }}' > Open MetaMask / Rabby </button> <a id="metamaskMobileLink" href="#" target="_blank" rel="noopener noreferrer" class="pay-btn pay-btn-mobile" data-invoice-id="{{ invoice.id }}" > Open in MetaMask Mobile </a> <button type="button" id="copyDetailsButton" class="pay-btn pay-btn-copy"> Copy Payment Details </button> </div> <div class="wallet-help"> <h4>Fastest way to pay</h4> <p>1. Click <strong>Open MetaMask / Rabby</strong> if your wallet is installed in this browser.</p> <p>2. If that does not open your wallet, click <strong>Open in MetaMask Mobile</strong>.</p> <p>3. If needed, use <strong>Copy Payment Details</strong> and send manually.</p> </div> <div class="wallet-note"> You do not need to finish everything inside the short quote timer. Once accepted, the quote is protected while you open your wallet. </div> <div class="copy-row"> <span id="walletStatusText"></span> <span id="copyStatusText" class="copy-status"></span> </div> {% elif pending_crypto_payment.txid %} <div style="margin-top:0.9rem;"><strong>Transaction Hash:</strong></div> <code class="lock-code mono">{{ pending_crypto_payment.txid }}</code> <div style="margin-top:0.75rem;">Transaction submitted and detected on RPC. Watching transaction / waiting for confirmation.</div> {% elif pending_crypto_payment.lock_expired %} <div style="margin-top:0.75rem;">price has expired - please refresh your quote to update</div> {% endif %} </div> </div> </div> {% else %} <form id="cryptoPickForm" method="post" action="/portal/invoice/{{ invoice.id }}/pay-crypto"> <table class="quote-table"> <thead><tr><th>Asset</th><th>Quoted Amount</th><th>CAD Price</th><th>Status</th><th>Action</th></tr></thead> <tbody> {% for q in crypto_options %} <tr> <td> {{ q.label }} {% if q.recommended %}<span class="quote-badge quote-live">recommended</span>{% endif %} {% if q.wallet_capable %}<span class="quote-badge quote-live">wallet</span>{% endif %} </td> <td>{{ q.display_amount or "—" }}</td> <td>{% if q.price_cad is not none %}{{ "%.8f"|format(q.price_cad|float) }}{% else %}—{% endif %}</td> <td>{% if q.available %}<span class="quote-badge quote-live">live</span>{% else %}<span class="quote-badge quote-stale">{{ q.reason or "unavailable" }}</span>{% endif %}</td> <td><button type="submit" name="asset" value="{{ q.symbol }}" class="quote-pick-btn" {% if not q.available %}disabled{% endif %}>Accept {{ q.symbol }}</button></td> </tr> {% endfor %} </tbody> </table> </form> {% endif %} </div> {% else %} <p>No crypto quote snapshot is available for this invoice yet.</p> {% endif %} </div> </div> {% endif %} {% if invoice_payments %} <div class="detail-card" style="margin-top:1.25rem;"> <h3>Payments Applied</h3> <table class="portal-table"> <thead> <tr> <th>Method</th> <th>Amount</th> <th>Status</th> <th>Received</th> <th>Reference / TXID</th> </tr> </thead> <tbody> {% for p in invoice_payments %} <tr> <td>{{ p.payment_method_label }}</td> <td>{{ p.payment_amount_display }} {{ p.payment_currency }}</td> <td>{{ p.payment_status }}</td> <td>{{ p.received_at_local }}</td> <td> {% if p.txid %} {{ p.txid }} {% elif p.reference %} {{ p.reference }} {% else %} - {% endif %} {% if p.wallet_address %}<br><small>{{ p.wallet_address }}</small>{% endif %} </td> </tr> {% endfor %} </tbody> </table> </div> {% endif %} {% if pdf_url %} <div style="margin-top:1rem;"><a href="/portal/invoice/{{ invoice.id }}/pdf" target="_blank" rel="noopener noreferrer">Open Invoice PDF</a></div> {% endif %}
</div> <script>
(function() { const select = document.getElementById("payMethodSelect"); if (select) { select.addEventListener("change", function() { const value = this.value || ""; const url = new URL(window.location.href); if (!value) { url.searchParams.delete("pay"); url.searchParams.delete("asset"); url.searchParams.delete("payment_id"); url.searchParams.delete("crypto_error"); url.searchParams.delete("refresh_quote"); } else { url.searchParams.set("pay", value); if (value !== "crypto") { url.searchParams.delete("asset"); url.searchParams.delete("payment_id"); url.searchParams.delete("crypto_error"); url.searchParams.delete("refresh_quote"); } else { url.searchParams.delete("asset"); url.searchParams.delete("payment_id"); url.searchParams.delete("crypto_error"); url.searchParams.set("refresh_quote", "1"); } } window.location.href = url.toString(); }); } function bindCountdown(valueId, labelId, expireIso, expiredMessage, disableSelector) { const valueEl = document.getElementById(valueId); const labelEl = document.getElementById(labelId); if (!valueEl || !expireIso) return; function tick() { const end = new Date(expireIso).getTime(); const now = Date.now(); const diff = Math.max(0, Math.floor((end - now) / 1000)); if (diff <= 0) { valueEl.textContent = "00:00"; valueEl.classList.add("snapshot-timer-expired"); if (labelEl) { labelEl.textContent = expiredMessage; labelEl.classList.add("snapshot-timer-expired"); } if (disableSelector) { document.querySelectorAll(disableSelector).forEach(btn => btn.disabled = true); } const lockBox = document.getElementById("lockBox"); if (lockBox) lockBox.classList.add("expired"); return; } const m = String(Math.floor(diff / 60)).padStart(2, "0"); const s = String(diff % 60).padStart(2, "0"); valueEl.textContent = `${m}:${s}`; setTimeout(tick, 250); } tick(); } const quoteTimer = document.getElementById("quoteTimerValue"); if (quoteTimer && quoteTimer.dataset.expiry) { bindCountdown("quoteTimerValue", "quoteTimerLabel", quoteTimer.dataset.expiry, "price has expired - please refresh your view to update", "#cryptoPickForm button"); } const lockTimer = document.getElementById("lockTimerValue"); if (lockTimer && lockTimer.dataset.expiry) { bindCountdown("lockTimerValue", "lockTimerLabel", lockTimer.dataset.expiry, "price has expired - please refresh your quote to update", "#walletPayButton"); } const processingTimer = document.getElementById("processingTimerValue"); if (processingTimer && processingTimer.dataset.expiry) { bindCountdown("processingTimerValue", "processingTimerLabel", processingTimer.dataset.expiry, "price has expired - please refresh your quote to update", null); } function toHexBigIntFromDecimal(amountText, decimals) { const text = String(amountText || "0"); const parts = text.split("."); const whole = parts[0] || "0"; const frac = (parts[1] || "").padEnd(decimals, "0").slice(0, decimals); const combined = (whole + frac).replace(/^0+/, "") || "0"; return "0x" + BigInt(combined).toString(16); } function erc20TransferData(to, amountText, decimals) { const method = "a9059cbb"; const addr = String(to || "").toLowerCase().replace(/^0x/, "").padStart(64, "0"); const amtHex = BigInt(toHexBigIntFromDecimal(amountText, decimals)).toString(16).padStart(64, "0"); return "0x" + method + addr + amtHex; } async function switchChain(chainId, chainAddParams) { const hexChainId = "0x" + Number(chainId).toString(16); try { await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: hexChainId }] }); return; } catch (err) { const code = err && (err.code ?? err?.data?.originalError?.code); if ((code === 4902 || String(err).includes("4902")) && chainAddParams) { await window.ethereum.request({ method: "wallet_addEthereumChain", params: [chainAddParams] }); await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: hexChainId }] }); return; } throw err; } } function buildMetaMaskMobileLink() { const currentUrl = window.location.href; return "https://link.metamask.io/dapp/" + currentUrl.replace(/^https?:\/\//, ""); } const mmLink = document.getElementById("metamaskMobileLink"); if (mmLink) { mmLink.href = buildMetaMaskMobileLink(); } async function copyText(text) { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; } const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); } const copyBtn = document.getElementById("copyDetailsButton"); if (copyBtn) { copyBtn.addEventListener("click", async function() { const copyStatus = document.getElementById("copyStatusText"); const walletAddress = document.getElementById("walletAddressText")?.textContent || ""; const invoiceRef = document.getElementById("invoiceRefText")?.textContent || ""; const amount = document.getElementById("walletPayButton")?.dataset.amount || ""; const asset = document.getElementById("walletPayButton")?.dataset.asset || ""; const payload =
`Asset: ${asset}
Amount: ${amount} ${asset}
Wallet: ${walletAddress}
Reference: ${invoiceRef}`; try { await copyText(payload); if (copyStatus) copyStatus.textContent = "Payment details copied."; } catch (err) { if (copyStatus) copyStatus.textContent = "Copy failed."; } }); } const walletButton = document.getElementById("walletPayButton"); function pendingTxStorageKey(invoiceId, paymentId) { return `otb_pending_tx_${invoiceId}_${paymentId}`; } async function submitTxHash(invoiceId, paymentId, asset, txHash) { const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify({ payment_id: paymentId, asset: asset, tx_hash: txHash }) }); let data = {}; try { data = await res.json(); } catch (err) { data = { ok: false, error: "invalid_json_response" }; } if (!res.ok || !data.ok) { throw new Error(data.error || `submit_failed_http_${res.status}`); } return data; } async function tryRecoverPendingTxFromStorage() { const invoiceId = "{{ invoice.id }}"; const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}"; const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}"; if (!invoiceId || !paymentId || !asset) return; {% if pending_crypto_payment and pending_crypto_payment.txid %} return; {% endif %} const key = pendingTxStorageKey(invoiceId, paymentId); const savedTx = localStorage.getItem(key); if (!savedTx || !savedTx.startsWith("0x")) return; const walletStatus = document.getElementById("walletStatusText"); try { if (walletStatus) walletStatus.textContent = "Retrying saved transaction submission..."; await submitTxHash(invoiceId, paymentId, asset, savedTx); localStorage.removeItem(key); const url = new URL(window.location.href); url.searchParams.set("pay", "crypto"); url.searchParams.set("asset", asset); url.searchParams.set("payment_id", paymentId); window.location.href = url.toString(); } catch (err) { if (walletStatus) walletStatus.textContent = `Saved tx retry failed: ${err.message}`; } } if (walletButton) { walletButton.addEventListener("click", async function() { const walletStatus = document.getElementById("walletStatusText"); const invoiceId = this.dataset.invoiceId; const paymentId = this.dataset.paymentId; const asset = this.dataset.asset; const chainId = this.dataset.chainId; const assetType = this.dataset.assetType; const to = this.dataset.to; const amount = this.dataset.amount; const decimals = Number(this.dataset.decimals || "18"); const tokenContract = this.dataset.tokenContract || ""; let chainAddParams = null; try { chainAddParams = this.dataset.chainAdd ? JSON.parse(this.dataset.chainAdd) : null; } catch (err) { chainAddParams = null; } const setStatus = (msg) => { if (walletStatus) walletStatus.textContent = msg; }; if (!window.ethereum || !window.ethereum.request) { setStatus("No browser wallet detected. Use MetaMask/Rabby or MetaMask Mobile."); return; } try { this.disabled = true; setStatus("Opening wallet..."); await window.ethereum.request({ method: "eth_requestAccounts" }); if (chainId && chainId !== "None" && chainId !== "") { try { await switchChain(Number(chainId), chainAddParams); } catch (err) { setStatus(`Chain switch failed: ${err.message || err}`); this.disabled = false; return; } } let txParams; if (assetType === "token" && tokenContract) { txParams = { from: (await window.ethereum.request({ method: "eth_accounts" }))[0], to: tokenContract, data: erc20TransferData(to, amount, decimals), value: "0x0" }; } else { txParams = { from: (await window.ethereum.request({ method: "eth_accounts" }))[0], to: to, value: toHexBigIntFromDecimal(amount, decimals) }; } setStatus("Waiting for wallet confirmation..."); const txHash = await window.ethereum.request({ method: "eth_sendTransaction", params: [txParams] }); if (!txHash || !String(txHash).startsWith("0x")) { throw new Error("wallet did not return a tx hash"); } const storageKey = pendingTxStorageKey(invoiceId, paymentId); localStorage.setItem(storageKey, String(txHash)); setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`); await submitTxHash(invoiceId, paymentId, asset, txHash); localStorage.removeItem(storageKey); setStatus("Transaction submitted. Reloading into processing view..."); const url = new URL(window.location.href); url.searchParams.set("pay", "crypto"); url.searchParams.set("asset", asset); url.searchParams.set("payment_id", paymentId); window.location.href = url.toString(); } catch (err) { setStatus(`Wallet submit failed: ${err.message || err}`); this.disabled = false; } }); } tryRecoverPendingTxFromStorage();
})();
</script> <script>
(function() { const processingAutoRefreshEnabled = {{ 'true' if pending_crypto_payment and pending_crypto_payment.txid and (invoice.status or '')|lower != 'paid' else 'false' }}; if (processingAutoRefreshEnabled) { setTimeout(function() { window.location.reload(); }, 10000); }
})();
</script>
{% include "includes/otb_footer.html" %}
</body>
</html>