update: ....

This commit is contained in:
nate 2026-03-19 01:02:52 +04:00
parent 1e6739b42a
commit 955b26f942
6 changed files with 267 additions and 194 deletions

View File

@ -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_status ON payments(status)`;
await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`; await sql`CREATE INDEX IF NOT EXISTS idx_payments_account ON payments(account_id)`;

View File

@ -1,41 +1,37 @@
/// Payment monitor: raw SSE stream for instant tx/block detection, /// Payment monitor: raw SSE stream for instant tx/block detection,
/// with bulk polling as fallback. /// with bulk polling as fallback.
/// States: pending → underpaid → confirming → paid (or expired)
import sql from "./db"; import sql from "./db";
import { getAddressInfo, getAddressInfoBulk } from "./freedom"; import { getAddressInfo, getAddressInfoBulk } from "./freedom";
import { COINS } from "./plans"; import { COINS } from "./plans";
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st"; 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 ────────────────────────────────────────────────── // ── In-memory maps ──────────────────────────────────────────────────
let addressMap = new Map<string, any>(); let addressMap = new Map<string, any>();
let confirmingMap = new Map<number, { payment: any; txids: Set<string> }>(); let txidLookup = new Map<string, number>(); // txid → payment.id
let txidLookup = new Map<string, number>();
const seenTxids = new Set<string>(); const seenTxids = new Set<string>();
async function refreshMaps() { async function refreshMaps() {
const active = await sql` const active = await sql`
SELECT * FROM payments SELECT * FROM payments
WHERE status IN ('pending', 'confirming') WHERE status IN ('pending', 'underpaid', 'confirming')
AND expires_at >= now() AND expires_at >= now()
`; `;
const newAddr = new Map<string, any>(); const newAddr = new Map<string, any>();
const newConfirming = new Map<number, { payment: any; txids: Set<string> }>();
const newTxidLookup = new Map<string, number>(); const newTxidLookup = new Map<string, number>();
for (const p of active) { for (const p of active) {
newAddr.set(p.address, p); newAddr.set(p.address, p);
if (p.status === "confirming" && p.txid) { if (p.txid) {
const existing = confirmingMap.get(p.id); // txid column may contain comma-separated txids
const txids = existing?.txids ?? new Set<string>(); for (const t of p.txid.split(",")) newTxidLookup.set(t, p.id);
txids.add(p.txid);
newConfirming.set(p.id, { payment: p, txids });
for (const t of txids) newTxidLookup.set(t, p.id);
} }
} }
addressMap = newAddr; addressMap = newAddr;
confirmingMap = newConfirming;
txidLookup = newTxidLookup; txidLookup = newTxidLookup;
for (const txid of seenTxids) { 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() { function startSSE() {
const url = `${SOCK_API}/sse`; connectSSE(`${SOCK_API}/sse`);
connectSSE(url);
} }
async function connectSSE(url: string) { async function connectSSE(url: string) {
@ -94,6 +89,8 @@ async function connectSSE(url: string) {
} }
} }
// ── SSE tx handler ──────────────────────────────────────────────────
async function handleTxEvent(event: any) { async function handleTxEvent(event: any) {
const outputs = event.data?.out ?? []; const outputs = event.data?.out ?? [];
const txHash = event.data?.tx?.hash ?? null; const txHash = event.data?.tx?.hash ?? null;
@ -111,82 +108,99 @@ async function handleTxEvent(event: any) {
const coin = COINS[payment.coin]; const coin = COINS[payment.coin];
if (!coin) continue; 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) { const prevReceived = parseFloat(payment.amount_received || "0");
try { const newReceived = prevReceived + txValue;
const info = await getAddressInfo(payment.address); const expected = parseFloat(payment.amount_crypto);
if (!info || info.error) continue; const threshold = expected * THRESHOLD;
const received = Number(info.received ?? 0);
const threshold = parseFloat(payment.amount_crypto) * 0.995; console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin} (total: ${newReceived}/${expected})`);
if (received >= threshold) {
await activatePayment(payment, txHash); // Append txid
addressMap.delete(addr); const txids = payment.txid ? payment.txid + "," + txHash : txHash;
}
} catch {} 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 { } else {
if (payment.status === "pending") { // 1+ conf, partial: underpaid
await sql`UPDATE payments SET status = 'confirming', txid = ${txHash} WHERE id = ${payment.id}`; await sql`UPDATE payments SET amount_received = ${newReceived.toFixed(8)}, txid = ${txids}, status = 'underpaid' WHERE id = ${payment.id}`;
payment.status = "confirming"; payment.amount_received = newReceived.toFixed(8);
payment.txid = txHash; payment.txid = txids;
console.log(`Payment ${payment.id} now confirming`); payment.status = "underpaid";
} for (const t of txids.split(",")) txidLookup.set(t, payment.id);
console.log(`Payment ${payment.id} underpaid: ${newReceived}/${expected}`);
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);
} }
return; // Only process first matching output set per tx
} }
} }
// ── SSE block handler ───────────────────────────────────────────────
async function handleBlockEvent(event: any) { async function handleBlockEvent(event: any) {
const blockTxs: string[] = event.data?.tx ?? []; const blockTxs: string[] = event.data?.tx ?? [];
if (blockTxs.length === 0) return; if (blockTxs.length === 0) return;
const toCheck = new Set<number>(); // Find payments with txids in this block
const paymentIds = new Set<number>();
for (const txid of blockTxs) { for (const txid of blockTxs) {
const paymentId = txidLookup.get(txid); const pid = txidLookup.get(txid);
if (paymentId != null) toCheck.add(paymentId); if (pid != null) paymentIds.add(pid);
} }
if (toCheck.size === 0) return; if (paymentIds.size === 0) return;
const addressesToCheck: string[] = []; for (const pid of paymentIds) {
const paymentsByAddress = new Map<string, { entry: { payment: any; txids: Set<string> }; paymentId: number }>(); // 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) { // Check confirmed amount via address API
const entry = confirmingMap.get(paymentId); let info: any;
if (!entry) continue; try { info = await getAddressInfo(payment.address); } catch { continue; }
addressesToCheck.push(entry.payment.address);
paymentsByAddress.set(entry.payment.address, { entry, paymentId });
}
if (addressesToCheck.length === 0) return;
let bulk: Record<string, any> = {};
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; }
}
if (!info || info.error) continue; if (!info || info.error) continue;
const receivedConfirmed = Number(info.received_confirmed ?? 0); 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) { if (receivedConfirmed >= threshold) {
console.log(`SSE: block confirmed payment ${paymentId}`); await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
const txid = entry.payment.txid || [...entry.txids][0] || null; await applyPlan(payment);
await activatePayment(entry.payment, txid); addressMap.delete(payment.address);
for (const t of entry.txids) txidLookup.delete(t); // Clean up txid lookups
confirmingMap.delete(paymentId); if (payment.txid) {
addressMap.delete(addr); 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() { export async function checkPayments() {
await sql` await sql`
UPDATE payments SET status = 'expired' UPDATE payments SET status = 'expired'
WHERE status IN ('pending', 'confirming') WHERE status IN ('pending', 'underpaid', 'confirming')
AND expires_at < now() AND expires_at < now()
`; `;
@ -204,7 +218,7 @@ export async function checkPayments() {
const allPayments = await sql` const allPayments = await sql`
SELECT * FROM payments SELECT * FROM payments
WHERE status IN ('pending', 'confirming') WHERE status IN ('pending', 'underpaid', 'confirming')
AND expires_at >= now() AND expires_at >= now()
`; `;
@ -231,23 +245,26 @@ export async function checkPayments() {
const received = Number(info.received ?? 0); const received = Number(info.received ?? 0);
const receivedConfirmed = Number(info.received_confirmed ?? 0); const receivedConfirmed = Number(info.received_confirmed ?? 0);
const expectedCrypto = parseFloat(payment.amount_crypto); const expected = parseFloat(payment.amount_crypto);
const threshold = expectedCrypto * 0.995; 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) { 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) { } 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) { } else if (received >= threshold) {
const txid = findTxid(info); await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'confirming' WHERE id = ${payment.id}`;
await sql`UPDATE payments SET status = 'confirming', txid = ${txid} WHERE id = ${payment.id}`; } else if (received > 0) {
console.log(`Poll: payment ${payment.id} now confirming`); await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, txid = ${txid}, status = 'underpaid' WHERE id = ${payment.id}`;
} }
} else if (payment.status === "confirming") { } else if (payment.status === "confirming") {
if (receivedConfirmed >= threshold) { if (receivedConfirmed >= threshold) {
const txid = payment.txid || findTxid(info); await sql`UPDATE payments SET amount_received = ${receivedConfirmed.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
await activatePayment(payment, txid); await applyPlan(payment);
} }
} }
} catch (e) { } catch (e) {
@ -258,46 +275,27 @@ export async function checkPayments() {
// ── Helpers ─────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────
/** Add a payment to the address map immediately (called from routes on checkout creation). */
export function watchPayment(payment: any) { export function watchPayment(payment: any) {
addressMap.set(payment.address, payment); addressMap.set(payment.address, payment);
} }
function findTxid(info: any): string | null { 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; return null;
} }
async function activatePayment(payment: any, txid: string | null) { async function applyPlan(payment: any) {
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;
if (payment.plan === "lifetime") { if (payment.plan === "lifetime") {
await sql` await sql`UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL WHERE id = ${payment.account_id}`;
UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL
WHERE id = ${payment.account_id}
`;
} else { } else {
const [account] = await sql` const [account] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}`;
SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}
`;
const now = new Date(); const now = new Date();
const currentExpiry = account.plan_expires_at ? new Date(account.plan_expires_at) : null; const currentExpiry = account.plan_expires_at ? new Date(account.plan_expires_at) : null;
const base = (account.plan === "pro" && currentExpiry && currentExpiry > now) ? currentExpiry : now; const base = (account.plan === "pro" && currentExpiry && currentExpiry > now) ? currentExpiry : now;
const newExpiry = new Date(base); const newExpiry = new Date(base);
newExpiry.setMonth(newExpiry.getMonth() + payment.months); 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}`); 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)); return new Promise(r => setTimeout(r, ms));
} }
// Start the single SSE connection immediately on import
startSSE(); startSSE();

View File

@ -115,6 +115,8 @@ export const routes = new Elysia()
amount_usd: Number(payment.amount_usd), amount_usd: Number(payment.amount_usd),
coin: payment.coin, coin: payment.coin,
amount_crypto: payment.amount_crypto, amount_crypto: payment.amount_crypto,
amount_received: "0",
amount_remaining: payment.amount_crypto,
address: payment.address, address: payment.address,
status: payment.status, status: payment.status,
expires_at: payment.expires_at, expires_at: payment.expires_at,
@ -138,7 +140,13 @@ export const routes = new Elysia()
if (!payment) { set.status = 404; return { error: "Payment not found" }; } if (!payment) { set.status = 404; return { error: "Payment not found" }; }
const coinInfo = COINS[payment.coin]; 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 { return {
id: payment.id, id: payment.id,
@ -147,6 +155,8 @@ export const routes = new Elysia()
amount_usd: Number(payment.amount_usd), amount_usd: Number(payment.amount_usd),
coin: payment.coin, coin: payment.coin,
amount_crypto: payment.amount_crypto, amount_crypto: payment.amount_crypto,
amount_received: payment.amount_received || "0",
amount_remaining: amountRemaining.toFixed(8),
address: payment.address, address: payment.address,
status: payment.status, status: payment.status,
created_at: payment.created_at, created_at: payment.created_at,

View File

@ -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 SELECT id, plan, months, amount_usd, coin, amount_crypto, status, created_at, paid_at, expires_at, txid
FROM payments FROM payments
WHERE account_id = ${accountId} 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 ORDER BY created_at DESC
LIMIT 20 LIMIT 20
`; `;

View File

@ -55,7 +55,6 @@
<div id="coin-section" class="hidden"> <div id="coin-section" class="hidden">
<label class="block text-sm text-gray-400 mb-2">Pay with</label> <label class="block text-sm text-gray-400 mb-2">Pay with</label>
<div id="coin-grid" class="grid grid-cols-3 gap-2"> <div id="coin-grid" class="grid grid-cols-3 gap-2">
<!-- Populated by JS -->
</div> </div>
</div> </div>
@ -84,6 +83,20 @@
<div class="text-xs text-gray-600 mt-1">$<span id="pay-usd"></span> USD</div> <div class="text-xs text-gray-600 mt-1">$<span id="pay-usd"></span> USD</div>
</div> </div>
<!-- Received / Remaining (shown on underpaid) -->
<div id="pay-received-section" class="hidden">
<div class="flex justify-center gap-6 text-sm">
<div>
<span class="text-gray-500">Received:</span>
<span class="text-green-400 font-mono" id="pay-received">0</span>
</div>
<div>
<span class="text-gray-500">Remaining:</span>
<span class="text-yellow-400 font-mono" id="pay-remaining">0</span>
</div>
</div>
</div>
<!-- Address --> <!-- Address -->
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Send to</label> <label class="block text-xs text-gray-500 mb-1">Send to</label>
@ -134,8 +147,13 @@
let selectedMonths = 1; let selectedMonths = 1;
let coins = []; let coins = [];
let paymentId = null; let paymentId = null;
let paymentData = null; // full payment object
let pollInterval = null; let pollInterval = null;
let countdownInterval = 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 // Fetch available coins on load
(async () => { (async () => {
@ -153,10 +171,7 @@
document.querySelectorAll('.plan-card').forEach(el => el.classList.remove('border-blue-500', 'border-yellow-500')); document.querySelectorAll('.plan-card').forEach(el => el.classList.remove('border-blue-500', 'border-yellow-500'));
const card = document.getElementById(`plan-${plan}`); const card = document.getElementById(`plan-${plan}`);
card.classList.add(plan === 'lifetime' ? 'border-yellow-500' : 'border-blue-500'); card.classList.add(plan === 'lifetime' ? 'border-yellow-500' : 'border-blue-500');
document.getElementById('months-section').classList.toggle('hidden', plan !== 'pro');
const monthsSection = document.getElementById('months-section');
monthsSection.classList.toggle('hidden', plan !== 'pro');
showCoins(); showCoins();
} }
@ -210,7 +225,6 @@
if (!res.ok) throw new Error(data.error || 'Checkout failed'); if (!res.ok) throw new Error(data.error || 'Checkout failed');
paymentId = data.id; paymentId = data.id;
// Update URL so refreshing restores this invoice
history.replaceState(null, '', `/dashboard/checkout/${data.id}`); history.replaceState(null, '', `/dashboard/checkout/${data.id}`);
showPayment(data); showPayment(data);
} catch (err) { } catch (err) {
@ -223,48 +237,94 @@
} }
function showPayment(data) { function showPayment(data) {
paymentData = data;
document.getElementById('step-select').classList.add('hidden'); document.getElementById('step-select').classList.add('hidden');
document.getElementById('step-pay').classList.remove('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-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 localReceived = parseFloat(data.amount_received || '0');
if (data.status === 'confirming') { updateAmountDisplay(data);
document.getElementById('pay-status').innerHTML = ` applyStatus(data.status, data);
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span>
`;
if (data.txid) watchedTxids.push(data.txid);
}
// Start countdown // Start countdown
const expiresAt = new Date(data.expires_at).getTime(); const expiresAt = new Date(data.expires_at).getTime();
updateCountdown(expiresAt); updateCountdown(expiresAt);
countdownInterval = setInterval(() => updateCountdown(expiresAt), 1000); countdownInterval = setInterval(() => updateCountdown(expiresAt), 1000);
// Start SSE for instant tx/block detection // Start SSE
watchAddress(data.coin, data.address); watchAddress(data.coin, data.address);
// Poll full checkout as fallback // Poll as fallback
pollInterval = setInterval(() => pollPayment(), 10000); pollInterval = setInterval(() => pollPayment(), 10000);
} }
let watchedAddress = null; function updateAmountDisplay(data) {
let watchedTxids = []; 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. if (received > 0 && remaining > 0) {
* Tracks multiple txids (user may send across several transactions). // Underpaid: show remaining as main amount
* On block, checks if any of our txids got confirmed. */ document.getElementById('pay-amount').textContent = remaining.toFixed(8);
let eventSource = null; 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 = `
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
<span class="text-yellow-400">Underpaid — please send the remaining amount</span>
`;
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 = `
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span>
`;
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) { function watchAddress(coin, address) {
if (eventSource) { eventSource.close(); eventSource = null; } if (eventSource) { eventSource.close(); eventSource = null; }
watchedAddress = address; watchedAddress = address;
watchedTxids = [];
eventSource = new EventSource(`${SOCK_API}/sse`); eventSource = new EventSource(`${SOCK_API}/sse`);
console.log('SSE: connected, watching for', address); console.log('SSE: connected, watching for', address);
@ -272,11 +332,8 @@
eventSource.onmessage = (e) => { eventSource.onmessage = (e) => {
try { try {
const event = JSON.parse(e.data); const event = JSON.parse(e.data);
if (event.type === 'block') { if (event.type === 'block') onBlock(event);
onBlock(event); else if (event.type === 'tx') onTx(event);
} else if (event.type === 'tx') {
onTx(event);
}
} catch {} } catch {}
}; };
@ -284,20 +341,40 @@
} }
function onTx(event) { function onTx(event) {
if (!watchedAddress) return; if (!watchedAddress || !paymentData) return;
const outputs = event.data?.out ?? []; 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) { for (const out of outputs) {
const addr = out?.script?.address; if (out?.script?.address === watchedAddress) {
if (!addr || addr !== watchedAddress) continue; txValue += Number(out.value ?? 0);
const txHash = event.data?.tx?.hash ?? null; }
if (!txHash || watchedTxids.includes(txHash)) return; }
watchedTxids.push(txHash); if (txValue === 0) return;
console.log('SSE: tx', txHash, 'matches our address');
document.getElementById('pay-status').innerHTML = ` watchedTxids.push(txHash);
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span> localReceived += txValue;
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span> console.log('SSE: tx', txHash, '+' + txValue, 'total:', localReceived);
`;
return; 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 ?? []; const blockTxs = event.data?.tx ?? [];
if (watchedTxids.some(t => blockTxs.includes(t))) { if (watchedTxids.some(t => blockTxs.includes(t))) {
console.log('SSE: block confirmed our tx'); console.log('SSE: block confirmed our tx');
// Show confirmed immediately, poll will finalize with backend applyStatus('paid', null);
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);
} }
} }
// ── 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) { function updateCountdown(expiresAt) {
const remaining = Math.max(0, expiresAt - Date.now()); const remaining = Math.max(0, expiresAt - Date.now());
const mins = Math.floor(remaining / 60000); const mins = Math.floor(remaining / 60000);
const secs = Math.floor((remaining % 60000) / 1000); const secs = Math.floor((remaining % 60000) / 1000);
document.getElementById('pay-countdown').textContent = `${mins}:${secs.toString().padStart(2, '0')}`; document.getElementById('pay-countdown').textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
if (remaining <= 0) { if (remaining <= 0) clearInterval(countdownInterval);
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 = `
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
<span class="text-blue-400">Transaction detected, waiting for confirmation...</span>
`;
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 {}
} }
function copyAddress() { function copyAddress() {
@ -361,7 +422,7 @@
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500); 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) %>; const PRELOAD_INVOICE_ID = <%~ JSON.stringify(it.invoiceId) %>;
if (PRELOAD_INVOICE_ID) { if (PRELOAD_INVOICE_ID) {
(async () => { (async () => {
@ -393,10 +454,13 @@
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
watchedAddress = null; watchedAddress = null;
watchedTxids = []; watchedTxids = [];
localReceived = 0;
paymentData = null;
document.getElementById('step-select').classList.remove('hidden'); document.getElementById('step-select').classList.remove('hidden');
document.getElementById('step-pay').classList.add('hidden'); document.getElementById('step-pay').classList.add('hidden');
document.getElementById('pay-success').classList.add('hidden'); document.getElementById('pay-success').classList.add('hidden');
document.getElementById('pay-expired').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-section').classList.remove('hidden');
document.getElementById('pay-status').innerHTML = ` document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>

View File

@ -52,14 +52,14 @@
<h2 class="text-sm font-semibold text-gray-300 mb-4">Invoices</h2> <h2 class="text-sm font-semibold text-gray-300 mb-4">Invoices</h2>
<div class="space-y-2"> <div class="space-y-2">
<% it.invoices.forEach(function(inv) { <% 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 statusColor = statusColors[inv.status] || 'gray';
const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); 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`; const planLabel = inv.plan === 'lifetime' ? 'Lifetime' : `Pro × ${inv.months}mo`;
%> %>
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50"> <div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-<%= statusColor %>-500 <%= inv.status === 'pending' || inv.status === 'confirming' ? 'animate-pulse' : '' %>"></span> <span class="w-2 h-2 rounded-full bg-<%= statusColor %>-500 <%= inv.status !== 'paid' ? 'animate-pulse' : '' %>"></span>
<div> <div>
<span class="text-sm text-gray-200"><%= planLabel %></span> <span class="text-sm text-gray-200"><%= planLabel %></span>
<span class="text-xs text-gray-600 ml-2">$<%= Number(inv.amount_usd).toFixed(2) %> · <%= inv.coin.toUpperCase() %></span> <span class="text-xs text-gray-600 ml-2">$<%= Number(inv.amount_usd).toFixed(2) %> · <%= inv.coin.toUpperCase() %></span>
@ -67,7 +67,7 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-xs text-gray-500"><%= date %></span> <span class="text-xs text-gray-500"><%= date %></span>
<% if (inv.status === 'pending' || inv.status === 'confirming') { %> <% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %>
<a href="/dashboard/checkout/<%= inv.id %>" class="text-xs text-blue-400 hover:text-blue-300">View</a> <a href="/dashboard/checkout/<%= inv.id %>" class="text-xs text-blue-400 hover:text-blue-300">View</a>
<% } else if (inv.status === 'paid' && inv.txid) { %> <% } else if (inv.status === 'paid' && inv.txid) { %>
<span class="text-xs text-green-500/70">Paid</span> <span class="text-xs text-green-500/70">Paid</span>