diff --git a/apps/pay/src/db.ts b/apps/pay/src/db.ts index ddb1836..5a1b425 100644 --- a/apps/pay/src/db.ts +++ b/apps/pay/src/db.ts @@ -31,6 +31,8 @@ export async function migrate() { ) `; + await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_received TEXT NOT NULL DEFAULT '0'`; + await sql`CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)`; await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`; diff --git a/apps/pay/src/monitor.ts b/apps/pay/src/monitor.ts index 56d62a4..81d0d73 100644 --- a/apps/pay/src/monitor.ts +++ b/apps/pay/src/monitor.ts @@ -1,41 +1,37 @@ /// Payment monitor: raw SSE stream for instant tx/block detection, /// with bulk polling as fallback. +/// States: pending → underpaid → confirming → paid (or expired) import sql from "./db"; import { getAddressInfo, getAddressInfoBulk } from "./freedom"; import { COINS } from "./plans"; const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st"; +const THRESHOLD = 0.995; // 0.5% tolerance for network fees // ── In-memory maps ────────────────────────────────────────────────── let addressMap = new Map(); -let confirmingMap = new Map }>(); -let txidLookup = new Map(); +let txidLookup = new Map(); // txid → payment.id const seenTxids = new Set(); async function refreshMaps() { const active = await sql` SELECT * FROM payments - WHERE status IN ('pending', 'confirming') + WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at >= now() `; const newAddr = new Map(); - const newConfirming = new Map }>(); const newTxidLookup = new Map(); for (const p of active) { newAddr.set(p.address, p); - if (p.status === "confirming" && p.txid) { - const existing = confirmingMap.get(p.id); - const txids = existing?.txids ?? new Set(); - txids.add(p.txid); - newConfirming.set(p.id, { payment: p, txids }); - for (const t of txids) newTxidLookup.set(t, p.id); + if (p.txid) { + // txid column may contain comma-separated txids + for (const t of p.txid.split(",")) newTxidLookup.set(t, p.id); } } addressMap = newAddr; - confirmingMap = newConfirming; txidLookup = newTxidLookup; for (const txid of seenTxids) { @@ -43,11 +39,10 @@ async function refreshMaps() { } } -// ── Single raw SSE connection — no query, all chains ──────────────── +// ── Single raw SSE connection ─────────────────────────────────────── function startSSE() { - const url = `${SOCK_API}/sse`; - connectSSE(url); + connectSSE(`${SOCK_API}/sse`); } async function connectSSE(url: string) { @@ -94,6 +89,8 @@ async function connectSSE(url: string) { } } +// ── SSE tx handler ────────────────────────────────────────────────── + async function handleTxEvent(event: any) { const outputs = event.data?.out ?? []; const txHash = event.data?.tx?.hash ?? null; @@ -111,82 +108,99 @@ async function handleTxEvent(event: any) { const coin = COINS[payment.coin]; if (!coin) continue; - console.log(`SSE: tx ${txHash} for payment ${payment.id} (${payment.coin})`); + // Sum output value going to our address in this tx + const txValue = outputs + .filter((o: any) => o?.script?.address === addr) + .reduce((sum: number, o: any) => sum + Number(o.value ?? 0), 0); - if (coin.confirmations === 0) { - try { - const info = await getAddressInfo(payment.address); - if (!info || info.error) continue; - const received = Number(info.received ?? 0); - const threshold = parseFloat(payment.amount_crypto) * 0.995; - if (received >= threshold) { - await activatePayment(payment, txHash); - addressMap.delete(addr); - } - } catch {} + const prevReceived = parseFloat(payment.amount_received || "0"); + const newReceived = prevReceived + txValue; + const expected = parseFloat(payment.amount_crypto); + const threshold = expected * THRESHOLD; + + console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin} (total: ${newReceived}/${expected})`); + + // Append txid + const txids = payment.txid ? payment.txid + "," + txHash : txHash; + + if (coin.confirmations === 0 && newReceived >= threshold) { + // 0-conf, full amount: activate immediately + await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`; + await applyPlan(payment); + addressMap.delete(addr); + console.log(`Payment ${payment.id} paid (0-conf)`); + } else if (coin.confirmations === 0 && newReceived > 0) { + // 0-conf, partial: underpaid + await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'underpaid' WHERE id = ${payment.id}`; + payment.amount_received = newReceived.toFixed(8); + payment.txid = txids; + payment.status = "underpaid"; + console.log(`Payment ${payment.id} underpaid (0-conf): ${newReceived}/${expected}`); + } else if (newReceived >= threshold) { + // 1+ conf, full amount: confirming + await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'confirming' WHERE id = ${payment.id}`; + payment.amount_received = newReceived.toFixed(8); + payment.txid = txids; + payment.status = "confirming"; + for (const t of txids.split(",")) txidLookup.set(t, payment.id); + console.log(`Payment ${payment.id} confirming: ${newReceived}/${expected}`); } else { - if (payment.status === "pending") { - await sql`UPDATE payments SET status = 'confirming', txid = ${txHash} WHERE id = ${payment.id}`; - payment.status = "confirming"; - payment.txid = txHash; - console.log(`Payment ${payment.id} now confirming`); - } - - let entry = confirmingMap.get(payment.id); - if (!entry) { - entry = { payment, txids: new Set() }; - confirmingMap.set(payment.id, entry); - } - entry.txids.add(txHash); - txidLookup.set(txHash, payment.id); + // 1+ conf, partial: underpaid + await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'underpaid' WHERE id = ${payment.id}`; + payment.amount_received = newReceived.toFixed(8); + payment.txid = txids; + payment.status = "underpaid"; + for (const t of txids.split(",")) txidLookup.set(t, payment.id); + console.log(`Payment ${payment.id} underpaid: ${newReceived}/${expected}`); } + + return; // Only process first matching output set per tx } } +// ── SSE block handler ─────────────────────────────────────────────── + async function handleBlockEvent(event: any) { const blockTxs: string[] = event.data?.tx ?? []; if (blockTxs.length === 0) return; - const toCheck = new Set(); + // Find payments with txids in this block + const paymentIds = new Set(); for (const txid of blockTxs) { - const paymentId = txidLookup.get(txid); - if (paymentId != null) toCheck.add(paymentId); + const pid = txidLookup.get(txid); + if (pid != null) paymentIds.add(pid); } - if (toCheck.size === 0) return; + if (paymentIds.size === 0) return; - const addressesToCheck: string[] = []; - const paymentsByAddress = new Map }; paymentId: number }>(); + for (const pid of paymentIds) { + // Re-fetch from DB for latest state + const [payment] = await sql` + SELECT * FROM payments WHERE id = ${pid} AND status IN ('underpaid', 'confirming') + `; + if (!payment) continue; - for (const paymentId of toCheck) { - const entry = confirmingMap.get(paymentId); - if (!entry) continue; - addressesToCheck.push(entry.payment.address); - paymentsByAddress.set(entry.payment.address, { entry, paymentId }); - } - - if (addressesToCheck.length === 0) return; - - let bulk: Record = {}; - try { bulk = await getAddressInfoBulk(addressesToCheck); } catch {} - - for (const [addr, { entry, paymentId }] of paymentsByAddress) { - let info = bulk[addr]; - if (!info) { - try { info = await getAddressInfo(addr); } catch { continue; } - } + // Check confirmed amount via address API + let info: any; + try { info = await getAddressInfo(payment.address); } catch { continue; } if (!info || info.error) continue; const receivedConfirmed = Number(info.received_confirmed ?? 0); - const threshold = parseFloat(entry.payment.amount_crypto) * 0.995; + const expected = parseFloat(payment.amount_crypto); + const threshold = expected * THRESHOLD; if (receivedConfirmed >= threshold) { - console.log(`SSE: block confirmed payment ${paymentId}`); - const txid = entry.payment.txid || [...entry.txids][0] || null; - await activatePayment(entry.payment, txid); - for (const t of entry.txids) txidLookup.delete(t); - confirmingMap.delete(paymentId); - addressMap.delete(addr); + await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`; + await applyPlan(payment); + addressMap.delete(payment.address); + // Clean up txid lookups + if (payment.txid) { + for (const t of payment.txid.split(",")) txidLookup.delete(t); + } + console.log(`Payment ${payment.id} paid (confirmed)`); + } else if (receivedConfirmed > 0) { + // Partially confirmed — update amount_received + await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)} WHERE id = ${payment.id}`; } } } @@ -196,7 +210,7 @@ async function handleBlockEvent(event: any) { export async function checkPayments() { await sql` UPDATE payments SET status = 'expired' - WHERE status IN ('pending', 'confirming') + WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at < now() `; @@ -204,7 +218,7 @@ export async function checkPayments() { const allPayments = await sql` SELECT * FROM payments - WHERE status IN ('pending', 'confirming') + WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at >= now() `; @@ -231,23 +245,26 @@ export async function checkPayments() { const received = Number(info.received ?? 0); const receivedConfirmed = Number(info.received_confirmed ?? 0); - const expectedCrypto = parseFloat(payment.amount_crypto); - const threshold = expectedCrypto * 0.995; + const expected = parseFloat(payment.amount_crypto); + const threshold = expected * THRESHOLD; + const txid = payment.txid || findTxid(info); - if (payment.status === "pending") { + if (payment.status === "pending" || payment.status === "underpaid") { if (coin.confirmations === 0 && received >= threshold) { - await activatePayment(payment, findTxid(info)); + await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`; + await applyPlan(payment); } else if (coin.confirmations > 0 && receivedConfirmed >= threshold) { - await activatePayment(payment, findTxid(info)); + await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, txid = ${txid}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`; + await applyPlan(payment); } else if (received >= threshold) { - const txid = findTxid(info); - await sql`UPDATE payments SET status = 'confirming', txid = ${txid} WHERE id = ${payment.id}`; - console.log(`Poll: payment ${payment.id} now confirming`); + await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'confirming' WHERE id = ${payment.id}`; + } else if (received > 0) { + await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'underpaid' WHERE id = ${payment.id}`; } } else if (payment.status === "confirming") { if (receivedConfirmed >= threshold) { - const txid = payment.txid || findTxid(info); - await activatePayment(payment, txid); + await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`; + await applyPlan(payment); } } } catch (e) { @@ -258,46 +275,27 @@ export async function checkPayments() { // ── Helpers ─────────────────────────────────────────────────────────── -/** Add a payment to the address map immediately (called from routes on checkout creation). */ export function watchPayment(payment: any) { addressMap.set(payment.address, payment); } function findTxid(info: any): string | null { - if (info.in?.length) return info.in[0].txid ?? null; + if (info.in?.length) return info.in.map((i: any) => i.txid).filter(Boolean).join(",") || null; return null; } -async function activatePayment(payment: any, txid: string | null) { - const [updated] = await sql` - UPDATE payments - SET status = 'paid', paid_at = now(), txid = ${txid} - WHERE id = ${payment.id} AND status != 'paid' - RETURNING id - `; - if (!updated) return; - +async function applyPlan(payment: any) { if (payment.plan === "lifetime") { - await sql` - UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL - WHERE id = ${payment.account_id} - `; + await sql`UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL WHERE id = ${payment.account_id}`; } else { - const [account] = await sql` - SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id} - `; + const [account] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}`; const now = new Date(); const currentExpiry = account.plan_expires_at ? new Date(account.plan_expires_at) : null; const base = (account.plan === "pro" && currentExpiry && currentExpiry > now) ? currentExpiry : now; const newExpiry = new Date(base); newExpiry.setMonth(newExpiry.getMonth() + payment.months); - - await sql` - UPDATE accounts SET plan = 'pro', plan_expires_at = ${newExpiry.toISOString()} - WHERE id = ${payment.account_id} - `; + await sql`UPDATE accounts SET plan = 'pro', plan_expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.account_id}`; } - console.log(`Payment ${payment.id} activated: ${payment.plan} for account ${payment.account_id}`); } @@ -317,5 +315,4 @@ function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); } -// Start the single SSE connection immediately on import startSSE(); diff --git a/apps/pay/src/routes.ts b/apps/pay/src/routes.ts index 1b7fe4c..10e407f 100644 --- a/apps/pay/src/routes.ts +++ b/apps/pay/src/routes.ts @@ -115,6 +115,8 @@ export const routes = new Elysia() amount_usd: Number(payment.amount_usd), coin: payment.coin, amount_crypto: payment.amount_crypto, + amount_received: "0", + amount_remaining: payment.amount_crypto, address: payment.address, status: payment.status, expires_at: payment.expires_at, @@ -138,7 +140,13 @@ export const routes = new Elysia() if (!payment) { set.status = 404; return { error: "Payment not found" }; } const coinInfo = COINS[payment.coin]; - const uri = `${coinInfo.uri}:${payment.address}?amount=${payment.amount_crypto}`; + const amountCrypto = parseFloat(payment.amount_crypto); + const amountReceived = parseFloat(payment.amount_received || "0"); + const amountRemaining = Math.max(0, amountCrypto - amountReceived); + + // QR shows remaining amount (or full amount if nothing received yet) + const qrAmount = amountRemaining > 0 ? amountRemaining.toFixed(8) : payment.amount_crypto; + const uri = `${coinInfo.uri}:${payment.address}?amount=${qrAmount}`; return { id: payment.id, @@ -147,6 +155,8 @@ export const routes = new Elysia() amount_usd: Number(payment.amount_usd), coin: payment.coin, amount_crypto: payment.amount_crypto, + amount_received: payment.amount_received || "0", + amount_remaining: amountRemaining.toFixed(8), address: payment.address, status: payment.status, created_at: payment.created_at, diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 675f13d..e24dc0a 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -211,7 +211,7 @@ export const dashboard = new Elysia() SELECT id, plan, months, amount_usd, coin, amount_crypto, status, created_at, paid_at, expires_at, txid FROM payments WHERE account_id = ${accountId} - AND (status = 'paid' OR (status IN ('pending', 'confirming') AND expires_at >= now())) + AND (status = 'paid' OR (status IN ('pending', 'underpaid', 'confirming') AND expires_at >= now())) ORDER BY created_at DESC LIMIT 20 `; diff --git a/apps/web/src/views/checkout.ejs b/apps/web/src/views/checkout.ejs index 207d03b..ed846e1 100644 --- a/apps/web/src/views/checkout.ejs +++ b/apps/web/src/views/checkout.ejs @@ -55,7 +55,6 @@ @@ -84,6 +83,20 @@
$ USD
+ + +
@@ -134,8 +147,13 @@ let selectedMonths = 1; let coins = []; let paymentId = null; + let paymentData = null; // full payment object let pollInterval = null; let countdownInterval = null; + let eventSource = null; + let watchedAddress = null; + let watchedTxids = []; + let localReceived = 0; // track received amount from SSE locally // Fetch available coins on load (async () => { @@ -153,10 +171,7 @@ document.querySelectorAll('.plan-card').forEach(el => el.classList.remove('border-blue-500', 'border-yellow-500')); const card = document.getElementById(`plan-${plan}`); card.classList.add(plan === 'lifetime' ? 'border-yellow-500' : 'border-blue-500'); - - const monthsSection = document.getElementById('months-section'); - monthsSection.classList.toggle('hidden', plan !== 'pro'); - + document.getElementById('months-section').classList.toggle('hidden', plan !== 'pro'); showCoins(); } @@ -210,7 +225,6 @@ if (!res.ok) throw new Error(data.error || 'Checkout failed'); paymentId = data.id; - // Update URL so refreshing restores this invoice history.replaceState(null, '', `/dashboard/checkout/${data.id}`); showPayment(data); } catch (err) { @@ -223,48 +237,94 @@ } function showPayment(data) { + paymentData = data; document.getElementById('step-select').classList.add('hidden'); document.getElementById('step-pay').classList.remove('hidden'); - document.getElementById('pay-qr').src = data.qr_url; - document.getElementById('pay-amount').textContent = data.amount_crypto; - document.getElementById('pay-coin-label').textContent = data.coin_label + ' (' + data.coin_ticker + ')'; - document.getElementById('pay-usd').textContent = data.amount_usd.toFixed(2); document.getElementById('pay-address').textContent = data.address; + document.getElementById('pay-coin-label').textContent = data.coin_label + ' (' + data.coin_ticker + ')'; + document.getElementById('pay-usd').textContent = Number(data.amount_usd).toFixed(2); - // Show current status immediately - if (data.status === 'confirming') { - document.getElementById('pay-status').innerHTML = ` - - Transaction detected, waiting for confirmation... - `; - if (data.txid) watchedTxids.push(data.txid); - } + localReceived = parseFloat(data.amount_received || '0'); + updateAmountDisplay(data); + applyStatus(data.status, data); // Start countdown const expiresAt = new Date(data.expires_at).getTime(); updateCountdown(expiresAt); countdownInterval = setInterval(() => updateCountdown(expiresAt), 1000); - // Start SSE for instant tx/block detection + // Start SSE watchAddress(data.coin, data.address); - // Poll full checkout as fallback + // Poll as fallback pollInterval = setInterval(() => pollPayment(), 10000); } - let watchedAddress = null; - let watchedTxids = []; + function updateAmountDisplay(data) { + const received = parseFloat(data.amount_received || '0'); + const total = parseFloat(data.amount_crypto); + const remaining = Math.max(0, total - received); - /** Listen to raw SSE via EventSource for this coin, match tx outputs locally. - * Tracks multiple txids (user may send across several transactions). - * On block, checks if any of our txids got confirmed. */ - let eventSource = null; + if (received > 0 && remaining > 0) { + // Underpaid: show remaining as main amount + document.getElementById('pay-amount').textContent = remaining.toFixed(8); + document.getElementById('pay-received-section').classList.remove('hidden'); + document.getElementById('pay-received').textContent = received.toFixed(8); + document.getElementById('pay-remaining').textContent = remaining.toFixed(8); + // Update QR with remaining amount + if (data.qr_url) document.getElementById('pay-qr').src = data.qr_url; + } else { + document.getElementById('pay-amount').textContent = data.amount_crypto; + document.getElementById('pay-received-section').classList.add('hidden'); + if (data.qr_url) document.getElementById('pay-qr').src = data.qr_url; + } + } + + function applyStatus(status, data) { + if (status === 'underpaid') { + document.getElementById('pay-status').innerHTML = ` + + Underpaid — please send the remaining amount + `; + if (data?.txid) { + for (const t of data.txid.split(',')) { + if (!watchedTxids.includes(t)) watchedTxids.push(t); + } + } + } else if (status === 'confirming') { + document.getElementById('pay-status').innerHTML = ` + + Transaction detected, waiting for confirmation... + `; + if (data?.txid) { + for (const t of data.txid.split(',')) { + if (!watchedTxids.includes(t)) watchedTxids.push(t); + } + } + } else if (status === 'paid') { + clearInterval(pollInterval); + clearInterval(countdownInterval); + if (eventSource) { eventSource.close(); eventSource = null; } + document.getElementById('pay-status-section').classList.add('hidden'); + document.getElementById('pay-received-section').classList.add('hidden'); + document.getElementById('pay-success').classList.remove('hidden'); + setTimeout(() => { window.location.href = '/dashboard/settings'; }, 3000); + } else if (status === 'expired') { + clearInterval(pollInterval); + clearInterval(countdownInterval); + if (eventSource) { eventSource.close(); eventSource = null; } + document.getElementById('pay-status-section').classList.add('hidden'); + document.getElementById('pay-received-section').classList.add('hidden'); + document.getElementById('pay-expired').classList.remove('hidden'); + } + } + + // ── SSE ────────────────────────────────────────────────────────── function watchAddress(coin, address) { if (eventSource) { eventSource.close(); eventSource = null; } watchedAddress = address; - watchedTxids = []; eventSource = new EventSource(`${SOCK_API}/sse`); console.log('SSE: connected, watching for', address); @@ -272,11 +332,8 @@ eventSource.onmessage = (e) => { try { const event = JSON.parse(e.data); - if (event.type === 'block') { - onBlock(event); - } else if (event.type === 'tx') { - onTx(event); - } + if (event.type === 'block') onBlock(event); + else if (event.type === 'tx') onTx(event); } catch {} }; @@ -284,20 +341,40 @@ } function onTx(event) { - if (!watchedAddress) return; + if (!watchedAddress || !paymentData) return; const outputs = event.data?.out ?? []; + const txHash = event.data?.tx?.hash ?? null; + if (!txHash || watchedTxids.includes(txHash)) return; + + // Sum outputs going to our address + let txValue = 0; for (const out of outputs) { - const addr = out?.script?.address; - if (!addr || addr !== watchedAddress) continue; - const txHash = event.data?.tx?.hash ?? null; - if (!txHash || watchedTxids.includes(txHash)) return; - watchedTxids.push(txHash); - console.log('SSE: tx', txHash, 'matches our address'); - document.getElementById('pay-status').innerHTML = ` - - Transaction detected, waiting for confirmation... - `; - return; + if (out?.script?.address === watchedAddress) { + txValue += Number(out.value ?? 0); + } + } + if (txValue === 0) return; + + watchedTxids.push(txHash); + localReceived += txValue; + console.log('SSE: tx', txHash, '+' + txValue, 'total:', localReceived); + + const expected = parseFloat(paymentData.amount_crypto); + const remaining = Math.max(0, expected - localReceived); + + // Update received display + document.getElementById('pay-received-section').classList.remove('hidden'); + document.getElementById('pay-received').textContent = localReceived.toFixed(8); + document.getElementById('pay-remaining').textContent = remaining.toFixed(8); + + if (remaining <= expected * 0.005) { + // Full amount received + document.getElementById('pay-amount').textContent = expected.toFixed(8); + applyStatus('confirming', null); + } else { + // Underpaid + document.getElementById('pay-amount').textContent = remaining.toFixed(8); + applyStatus('underpaid', null); } } @@ -306,51 +383,35 @@ const blockTxs = event.data?.tx ?? []; if (watchedTxids.some(t => blockTxs.includes(t))) { console.log('SSE: block confirmed our tx'); - // Show confirmed immediately, poll will finalize with backend - clearInterval(countdownInterval); - if (eventSource) { eventSource.close(); eventSource = null; } - document.getElementById('pay-status-section').classList.add('hidden'); - document.getElementById('pay-success').classList.remove('hidden'); - setTimeout(() => { window.location.href = '/dashboard/settings'; }, 3000); + applyStatus('paid', null); } } + // ── Polling fallback ───────────────────────────────────────────── + + async function pollPayment() { + try { + const res = await fetch(`${PAY_API}/checkout/${paymentId}`, { credentials: 'include' }); + const data = await res.json(); + paymentData = data; + + // Sync local received from server if server knows more + const serverReceived = parseFloat(data.amount_received || '0'); + if (serverReceived > localReceived) localReceived = serverReceived; + + updateAmountDisplay({ ...data, amount_received: localReceived.toFixed(8) }); + applyStatus(data.status, data); + } catch {} + } + + // ── Utilities ──────────────────────────────────────────────────── + function updateCountdown(expiresAt) { const remaining = Math.max(0, expiresAt - Date.now()); const mins = Math.floor(remaining / 60000); const secs = Math.floor((remaining % 60000) / 1000); document.getElementById('pay-countdown').textContent = `${mins}:${secs.toString().padStart(2, '0')}`; - if (remaining <= 0) { - clearInterval(countdownInterval); - } - } - - async function pollPayment() { - try { - const res = await fetch(`${PAY_API}/checkout/${paymentId}`, { credentials: 'include' }); - const data = await res.json(); - - if (data.status === 'confirming') { - document.getElementById('pay-status').innerHTML = ` - - Transaction detected, waiting for confirmation... - `; - if (data.txid && !watchedTxids.includes(data.txid)) watchedTxids.push(data.txid); - } else if (data.status === 'paid') { - clearInterval(pollInterval); - clearInterval(countdownInterval); - if (eventSource) { eventSource.close(); eventSource = null; } - document.getElementById('pay-status-section').classList.add('hidden'); - document.getElementById('pay-success').classList.remove('hidden'); - setTimeout(() => { window.location.href = '/dashboard/settings'; }, 3000); - } else if (data.status === 'expired') { - clearInterval(pollInterval); - clearInterval(countdownInterval); - if (eventSource) { eventSource.close(); eventSource = null; } - document.getElementById('pay-status-section').classList.add('hidden'); - document.getElementById('pay-expired').classList.remove('hidden'); - } - } catch {} + if (remaining <= 0) clearInterval(countdownInterval); } function copyAddress() { @@ -361,7 +422,7 @@ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500); } - // Auto-load existing invoice if arriving via /dashboard/checkout/:id + // Auto-load existing invoice const PRELOAD_INVOICE_ID = <%~ JSON.stringify(it.invoiceId) %>; if (PRELOAD_INVOICE_ID) { (async () => { @@ -393,10 +454,13 @@ if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } watchedAddress = null; watchedTxids = []; + localReceived = 0; + paymentData = null; document.getElementById('step-select').classList.remove('hidden'); document.getElementById('step-pay').classList.add('hidden'); document.getElementById('pay-success').classList.add('hidden'); document.getElementById('pay-expired').classList.add('hidden'); + document.getElementById('pay-received-section').classList.add('hidden'); document.getElementById('pay-status-section').classList.remove('hidden'); document.getElementById('pay-status').innerHTML = ` diff --git a/apps/web/src/views/settings.ejs b/apps/web/src/views/settings.ejs index f7fe0d0..00e7860 100644 --- a/apps/web/src/views/settings.ejs +++ b/apps/web/src/views/settings.ejs @@ -52,14 +52,14 @@

Invoices

<% it.invoices.forEach(function(inv) { - const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow' }; + const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' }; const statusColor = statusColors[inv.status] || 'gray'; const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); const planLabel = inv.plan === 'lifetime' ? 'Lifetime' : `Pro × ${inv.months}mo`; %>
- +
<%= planLabel %> $<%= Number(inv.amount_usd).toFixed(2) %> · <%= inv.coin.toUpperCase() %> @@ -67,7 +67,7 @@
<%= date %> - <% if (inv.status === 'pending' || inv.status === 'confirming') { %> + <% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %> View <% } else if (inv.status === 'paid' && inv.txid) { %> Paid