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.
 
 
 

639 lines
33 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">
<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>
<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="https://outsidethebox.top/">Home</a>
<a href="mailto:support@outsidethebox.top?subject=Portal%20Support">Contact 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 '' }}"
>
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>
{% if pending_crypto_payment and pending_crypto_payment.txid %}
<div class="snapshot-timer-box">
<div id="processingTimerSideValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.processing_expires_at_iso }}">--:--</div>
<div id="processingTimerSideLabel" class="snapshot-timer-label">Watching transaction / waiting for confirmation</div>
</div>
{% else %}
<div class="snapshot-timer-box">
<div id="lockTimerSideValue" class="snapshot-timer-value" data-expiry="{{ pending_crypto_payment.lock_expires_at_iso }}">--:--</div>
<div id="lockTimerSideLabel" class="snapshot-timer-label">Quote protected while you open wallet</div>
</div>
{% endif %}
</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 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 lockTimerSide = document.getElementById("lockTimerSideValue");
if (lockTimerSide && lockTimerSide.dataset.expiry) {
bindCountdown("lockTimerSideValue", "lockTimerSideLabel", lockTimerSide.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);
}
const processingTimerSide = document.getElementById("processingTimerSideValue");
if (processingTimerSide && processingTimerSide.dataset.expiry) {
bindCountdown("processingTimerSideValue", "processingTimerSideLabel", processingTimerSide.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) {
const hexChainId = "0x" + Number(chainId).toString(16);
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: hexChainId }]
});
}
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");
if (walletButton) {
walletButton.addEventListener("click", async function() {
const statusEl = document.getElementById("walletStatusText");
const originalText = walletButton.textContent;
walletButton.disabled = true;
if (statusEl) statusEl.textContent = "Opening wallet…";
try {
if (!window.ethereum) {
throw new Error("No wallet detected in this browser. Use 'Open in MetaMask Mobile' or copy the payment details.");
}
const invoiceId = walletButton.dataset.invoiceId;
const paymentId = walletButton.dataset.paymentId;
const asset = walletButton.dataset.asset;
const chainId = walletButton.dataset.chainId;
const assetType = walletButton.dataset.assetType;
const to = walletButton.dataset.to;
const amount = walletButton.dataset.amount;
const decimals = Number(walletButton.dataset.decimals || "18");
const tokenContract = walletButton.dataset.tokenContract || "";
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" });
const from = Array.isArray(accounts) && accounts.length ? accounts[0] : "";
if (!from) {
throw new Error("Wallet did not return an account address.");
}
await switchChain(chainId);
let txHash;
if (assetType === "native") {
txHash = await window.ethereum.request({
method: "eth_sendTransaction",
params: [{
from: from,
to: to,
value: toHexBigIntFromDecimal(amount, decimals)
}]
});
} else {
txHash = await window.ethereum.request({
method: "eth_sendTransaction",
params: [{
from: from,
to: tokenContract,
data: erc20TransferData(to, amount, decimals)
}]
});
}
if (statusEl) statusEl.textContent = "Submitting transaction hash to portal…";
async function submitTxHashWithRetry() {
const maxAttempts = 12; // about 60 seconds total
const waitMs = 5000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (statusEl) {
statusEl.textContent = `Checking RPC for transaction… attempt ${attempt}/${maxAttempts}`;
}
const resp = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payment_id: paymentId,
asset: asset,
tx_hash: txHash
})
});
const data = await resp.json();
if (resp.ok && data.ok) {
window.location.href = data.redirect_url;
return;
}
const detail = String((data && (data.detail || data.error)) || "");
const retryable = detail.toLowerCase().includes("not found on rpc");
if (!retryable || attempt === maxAttempts) {
throw new Error(detail || "Portal rejected tx hash");
}
if (statusEl) {
statusEl.textContent = `Transaction sent. Waiting for RPC to see it… retrying in ${Math.floor(waitMs / 1000)}s`;
}
await new Promise(resolve => setTimeout(resolve, waitMs));
}
}
await submitTxHashWithRetry();
} catch (err) {
if (statusEl) statusEl.textContent = String(err.message || err);
walletButton.disabled = false;
walletButton.textContent = originalText;
}
});
}
})();
</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 "footer.html" %}
</body>
</html>