refactor: simplify payment gateway, single evaluatePayment function, clean landing page pricing
This commit is contained in:
parent
df638c94f1
commit
632f006988
|
|
@ -1,8 +1,7 @@
|
|||
/// HD address derivation using bitcore-lib family.
|
||||
/// Each coin uses its own xpub from env vars.
|
||||
/// Derives child addresses at m/0/{index} (external receive chain).
|
||||
|
||||
// @ts-ignore — bitcore libs don't have perfect types
|
||||
// @ts-ignore
|
||||
import bitcore from "bitcore-lib";
|
||||
import bs58check from "bs58check";
|
||||
// @ts-ignore
|
||||
|
|
@ -14,18 +13,6 @@ import bitcoreDoge from "bitcore-lib-doge";
|
|||
// @ts-ignore
|
||||
import dashcore from "@dashevo/dashcore-lib";
|
||||
|
||||
interface CoinLib {
|
||||
HDPublicKey: any;
|
||||
}
|
||||
|
||||
const LIBS: Record<string, CoinLib> = {
|
||||
btc: bitcore,
|
||||
bch: bitcoreCash,
|
||||
ltc: bitcoreLtc,
|
||||
doge: bitcoreDoge,
|
||||
dash: dashcore,
|
||||
};
|
||||
|
||||
function getXpub(coin: string): string {
|
||||
const key = `XPUB_${coin.toUpperCase()}`;
|
||||
const val = process.env[key];
|
||||
|
|
@ -33,40 +20,7 @@ function getXpub(coin: string): string {
|
|||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a receive address for the given coin at the given index.
|
||||
* Uses the standard BIP44 external chain: m/0/{index}
|
||||
*/
|
||||
export function deriveAddress(coin: string, index: number): string {
|
||||
if (coin === "xec") {
|
||||
// XEC uses the same address format as BCH but with ecash: prefix
|
||||
// Derive using bitcore-lib-cash, then convert prefix
|
||||
const xpub = getXpub("xec");
|
||||
const hdPub = new bitcoreCash.HDPublicKey(xpub);
|
||||
const child = hdPub.deriveChild(0).deriveChild(index);
|
||||
const addr = new bitcoreCash.Address(child.publicKey, bitcoreCash.Networks.mainnet);
|
||||
// bitcore-lib-cash gives "bitcoincash:q..." — replace prefix with "ecash:"
|
||||
const cashAddr = addr.toCashAddress();
|
||||
return cashAddr.replace(/^bitcoincash:/, "ecash:");
|
||||
}
|
||||
|
||||
const lib = LIBS[coin];
|
||||
if (!lib) throw new Error(`Unsupported coin: ${coin}`);
|
||||
|
||||
const xpub = getXpub(coin);
|
||||
const hdPub = new lib.HDPublicKey(xpub);
|
||||
const child = hdPub.deriveChild(0).deriveChild(index);
|
||||
const addr = new lib.HDPublicKey.prototype.constructor.Address
|
||||
? new (lib as any).Address(child.publicKey)
|
||||
: child.publicKey.toAddress();
|
||||
|
||||
return addr.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpler approach — derive using each lib's built-in methods.
|
||||
*/
|
||||
export function deriveAddressSafe(coin: string, index: number): string {
|
||||
export function derive(coin: string, index: number): string {
|
||||
const xpub = getXpub(coin === "xec" ? "xec" : coin);
|
||||
|
||||
if (coin === "btc") {
|
||||
|
|
@ -83,15 +37,12 @@ export function deriveAddressSafe(coin: string, index: number): string {
|
|||
return addr.replace(/^bitcoincash:/, "ecash:");
|
||||
}
|
||||
if (coin === "ltc") {
|
||||
// All zpub/Ltub/Mtub/xpub variants share the same key data — only version bytes differ.
|
||||
// Remap to the standard xpub version (0x0488b21e) so bitcore-lib-ltc can parse it,
|
||||
// then derive a native segwit P2WPKH (bech32 ltc1q...) address.
|
||||
// Remap zpub/Ltub/Mtub to standard xpub version bytes for bitcore-lib-ltc
|
||||
const decoded = Buffer.from(bs58check.decode(xpub));
|
||||
decoded[0] = 0x04; decoded[1] = 0x88; decoded[2] = 0xb2; decoded[3] = 0x1e;
|
||||
const normalized = bs58check.encode(decoded);
|
||||
const hd = new bitcoreLtc.HDPublicKey(normalized);
|
||||
const pubkey = hd.deriveChild(0).deriveChild(index).publicKey;
|
||||
// Derive as P2WPKH (native segwit, bech32 ltc1q...)
|
||||
return new bitcoreLtc.Address(pubkey, bitcoreLtc.Networks.mainnet, bitcoreLtc.Address.PayToWitnessPublicKeyHash).toString();
|
||||
}
|
||||
if (coin === "doge") {
|
||||
|
|
@ -105,5 +56,3 @@ export function deriveAddressSafe(coin: string, index: number): string {
|
|||
|
||||
throw new Error(`Unsupported coin: ${coin}`);
|
||||
}
|
||||
|
||||
export { deriveAddressSafe as derive };
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ export async function getAddressInfo(address: string): Promise<any> {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
/** Bulk address lookup — POST /address with { terms: [...] }
|
||||
* Normalizes response to { address: info } map regardless of API format. */
|
||||
export async function getAddressInfoBulk(addresses: string[]): Promise<Record<string, any>> {
|
||||
if (addresses.length === 0) return {};
|
||||
const res = await fetch(`${API}/address`, {
|
||||
|
|
@ -24,39 +22,13 @@ export async function getAddressInfoBulk(addresses: string[]): Promise<Record<st
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ terms: addresses }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
// Normalize: if response is already keyed by address, return as-is.
|
||||
// If it's an array, index by address field.
|
||||
if (Array.isArray(data)) {
|
||||
const map: Record<string, any> = {};
|
||||
for (const item of data) {
|
||||
if (item?.address) map[item.address] = item;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Check if it's keyed by address — verify first value has address-like fields
|
||||
const firstKey = Object.keys(data)[0];
|
||||
if (firstKey && data[firstKey]?.address) return data;
|
||||
|
||||
// If keyed by index (0, 1, 2...), map back to addresses
|
||||
if (firstKey === "0" || firstKey === "1") {
|
||||
const map: Record<string, any> = {};
|
||||
for (let i = 0; i < addresses.length; i++) {
|
||||
if (data[i]) map[addresses[i]] = data[i];
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
return data;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function getQrUrl(text: string): string {
|
||||
return `${API}/invoice/qr/${encodeURIComponent(text)}`;
|
||||
}
|
||||
|
||||
/** Returns coin keys where the chain is online (no null uptime field). */
|
||||
export async function getAvailableCoins(): Promise<string[]> {
|
||||
const info = await getChainInfo();
|
||||
return Object.entries(info)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
/// Payment monitor: raw SSE stream for instant tx/block detection,
|
||||
/// with bulk polling as fallback.
|
||||
/// States: pending → underpaid → confirming → paid (or expired)
|
||||
/// Payment monitor: raw SSE + polling fallback.
|
||||
/// States: pending → underpaid → confirming → paid | expired
|
||||
import sql from "./db";
|
||||
import { getAddressInfo, getAddressInfoBulk } from "./freedom";
|
||||
import { COINS } from "./plans";
|
||||
|
|
@ -8,7 +7,7 @@ import { COINS } from "./plans";
|
|||
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st";
|
||||
const THRESHOLD = 0.995;
|
||||
|
||||
// ── In-memory maps ──────────────────────────────────────────────────
|
||||
// ── In-memory lookups for SSE matching ──────────────────────────────
|
||||
let addressMap = new Map<string, any>(); // address → payment
|
||||
let txidToPayment = new Map<string, number>(); // txid → payment.id
|
||||
const seenTxids = new Set<string>();
|
||||
|
|
@ -19,15 +18,11 @@ async function refreshMaps() {
|
|||
WHERE status IN ('pending', 'underpaid', 'confirming')
|
||||
AND expires_at >= now()
|
||||
`;
|
||||
|
||||
const newAddr = new Map<string, any>();
|
||||
const newTxid = new Map<string, number>();
|
||||
|
||||
for (const p of active) {
|
||||
newAddr.set(p.address, p);
|
||||
}
|
||||
for (const p of active) newAddr.set(p.address, p);
|
||||
|
||||
// Load all txids for active payments
|
||||
if (active.length > 0) {
|
||||
const ids = active.map((p: any) => p.id);
|
||||
const txs = await sql`SELECT payment_id, txid FROM payment_txs WHERE payment_id = ANY(${ids})`;
|
||||
|
|
@ -36,13 +31,104 @@ async function refreshMaps() {
|
|||
|
||||
addressMap = newAddr;
|
||||
txidToPayment = newTxid;
|
||||
for (const t of seenTxids) { if (!newTxid.has(t)) seenTxids.delete(t); }
|
||||
}
|
||||
|
||||
for (const txid of seenTxids) {
|
||||
if (!newTxid.has(txid)) seenTxids.delete(txid);
|
||||
// ── Core logic: one place for all state transitions ─────────────────
|
||||
|
||||
async function recordTx(paymentId: number, txid: string, amount: number, confirmed: boolean) {
|
||||
const [ins] = await sql`
|
||||
INSERT INTO payment_txs (payment_id, txid, amount, confirmed)
|
||||
VALUES (${paymentId}, ${txid}, ${amount.toFixed(8)}, ${confirmed})
|
||||
ON CONFLICT (payment_id, txid) DO UPDATE SET confirmed = EXCLUDED.confirmed OR payment_txs.confirmed
|
||||
RETURNING (xmax = 0) as is_new
|
||||
`;
|
||||
// Extend expiry to 24h on new tx
|
||||
if (ins?.is_new) {
|
||||
const exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
await sql`UPDATE payments SET expires_at = ${exp} WHERE id = ${paymentId} AND expires_at < ${exp}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single raw SSE connection ───────────────────────────────────────
|
||||
async function evaluatePayment(paymentId: number) {
|
||||
const [payment] = await sql`
|
||||
SELECT * FROM payments WHERE id = ${paymentId} AND status IN ('pending', 'underpaid', 'confirming')
|
||||
`;
|
||||
if (!payment) return;
|
||||
|
||||
const coin = COINS[payment.coin];
|
||||
if (!coin) return;
|
||||
|
||||
const [{ total, unconfirmed }] = await sql`
|
||||
SELECT COALESCE(SUM(amount::numeric), 0) as total,
|
||||
COUNT(*) FILTER (WHERE NOT confirmed)::int as unconfirmed
|
||||
FROM payment_txs WHERE payment_id = ${paymentId}
|
||||
`;
|
||||
|
||||
const received = Number(total);
|
||||
const expected = parseFloat(payment.amount_crypto);
|
||||
const threshold = expected * THRESHOLD;
|
||||
|
||||
let newStatus = payment.status;
|
||||
if (received >= threshold && (coin.confirmations === 0 || Number(unconfirmed) === 0)) {
|
||||
newStatus = "paid";
|
||||
} else if (received >= threshold) {
|
||||
newStatus = "confirming";
|
||||
} else if (received > 0) {
|
||||
newStatus = "underpaid";
|
||||
}
|
||||
|
||||
if (newStatus === payment.status && received <= parseFloat(payment.amount_received || "0")) return;
|
||||
|
||||
if (newStatus === "paid") {
|
||||
await sql`UPDATE payments SET status = 'paid', amount_received = ${received.toFixed(8)}, paid_at = now() WHERE id = ${paymentId} AND status != 'paid'`;
|
||||
await applyPlan(payment);
|
||||
addressMap.delete(payment.address);
|
||||
console.log(`Payment ${paymentId} paid`);
|
||||
} else {
|
||||
await sql`UPDATE payments SET status = ${newStatus}, amount_received = ${received.toFixed(8)} WHERE id = ${paymentId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleTxEvent(event: any) {
|
||||
const txHash = event.data?.tx?.hash;
|
||||
if (!txHash || seenTxids.has(txHash)) return;
|
||||
seenTxids.add(txHash);
|
||||
|
||||
const outputs = event.data?.out ?? [];
|
||||
for (const out of outputs) {
|
||||
const addr = out?.script?.address;
|
||||
const payment = addr && addressMap.get(addr);
|
||||
if (!payment) continue;
|
||||
|
||||
const txValue = outputs
|
||||
.filter((o: any) => o?.script?.address === addr)
|
||||
.reduce((s: number, o: any) => s + Number(o.value ?? 0), 0);
|
||||
if (txValue <= 0) continue;
|
||||
|
||||
console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin}`);
|
||||
await recordTx(payment.id, txHash, txValue, false);
|
||||
txidToPayment.set(txHash, payment.id);
|
||||
await evaluatePayment(payment.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlockEvent(event: any) {
|
||||
const blockTxs: string[] = event.data?.tx ?? [];
|
||||
const matched = blockTxs.filter(t => txidToPayment.has(t));
|
||||
if (matched.length === 0) return;
|
||||
|
||||
await sql`UPDATE payment_txs SET confirmed = true WHERE txid = ANY(${matched})`;
|
||||
|
||||
const pids = new Set(matched.map(t => txidToPayment.get(t)!));
|
||||
for (const pid of pids) {
|
||||
console.log(`SSE: block confirmed tx for payment ${pid}`);
|
||||
await evaluatePayment(pid);
|
||||
}
|
||||
}
|
||||
|
||||
function startSSE() {
|
||||
connectSSE(`${SOCK_API}/sse`);
|
||||
|
|
@ -52,35 +138,26 @@ async function connectSSE(url: string) {
|
|||
while (true) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok || !res.body) {
|
||||
console.error(`SSE: HTTP ${res.status}`);
|
||||
await sleep(5000);
|
||||
continue;
|
||||
}
|
||||
if (!res.ok || !res.body) { await sleep(5000); continue; }
|
||||
console.log("SSE: connected");
|
||||
|
||||
console.log("SSE: connected (raw, all chains)");
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let buf = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
try {
|
||||
const json = line.startsWith("data: ") ? line.slice(6) : line.slice(5);
|
||||
const event = JSON.parse(json);
|
||||
if (event.type === "block") {
|
||||
handleBlockEvent(event).catch(() => {});
|
||||
} else if (event.type === "tx") {
|
||||
handleTxEvent(event).catch(() => {});
|
||||
}
|
||||
const event = JSON.parse(line.slice(line.indexOf("{")));
|
||||
if (event.type === "block") handleBlockEvent(event).catch(() => {});
|
||||
else if (event.type === "tx") handleTxEvent(event).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,226 +169,42 @@ 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;
|
||||
if (!txHash) return;
|
||||
if (seenTxids.has(txHash)) return;
|
||||
seenTxids.add(txHash);
|
||||
|
||||
for (const out of outputs) {
|
||||
const addr = out?.script?.address;
|
||||
if (!addr) continue;
|
||||
|
||||
const payment = addressMap.get(addr);
|
||||
if (!payment) continue;
|
||||
|
||||
const coin = COINS[payment.coin];
|
||||
if (!coin) continue;
|
||||
|
||||
// Sum outputs going to our address
|
||||
const txValue = outputs
|
||||
.filter((o: any) => o?.script?.address === addr)
|
||||
.reduce((sum: number, o: any) => sum + Number(o.value ?? 0), 0);
|
||||
|
||||
if (txValue <= 0) continue;
|
||||
|
||||
console.log(`SSE: tx ${txHash} for payment ${payment.id}: +${txValue} ${payment.coin}`);
|
||||
|
||||
// Insert into payment_txs (ignore duplicate)
|
||||
const [inserted] = await sql`
|
||||
INSERT INTO payment_txs (payment_id, txid, amount)
|
||||
VALUES (${payment.id}, ${txHash}, ${txValue.toFixed(8)})
|
||||
ON CONFLICT (payment_id, txid) DO NOTHING
|
||||
RETURNING id
|
||||
`;
|
||||
txidToPayment.set(txHash, payment.id);
|
||||
|
||||
// Extend expiry to 24h on first tx detection
|
||||
if (inserted) {
|
||||
const newExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await sql`UPDATE payments SET expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.id} AND expires_at < ${newExpiry.toISOString()}`;
|
||||
}
|
||||
|
||||
// Recalculate total received
|
||||
const [{ total }] = await sql`
|
||||
SELECT COALESCE(SUM(amount::numeric), 0) as total FROM payment_txs WHERE payment_id = ${payment.id}
|
||||
`;
|
||||
const totalReceived = Number(total);
|
||||
const expected = parseFloat(payment.amount_crypto);
|
||||
const threshold = expected * THRESHOLD;
|
||||
|
||||
if (coin.confirmations === 0 && totalReceived >= threshold) {
|
||||
await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, 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 (totalReceived >= threshold) {
|
||||
await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'confirming' WHERE id = ${payment.id}`;
|
||||
payment.amount_received = totalReceived.toFixed(8);
|
||||
payment.status = "confirming";
|
||||
console.log(`Payment ${payment.id} confirming: ${totalReceived}/${expected}`);
|
||||
} else {
|
||||
await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'underpaid' WHERE id = ${payment.id}`;
|
||||
payment.amount_received = totalReceived.toFixed(8);
|
||||
payment.status = "underpaid";
|
||||
console.log(`Payment ${payment.id} underpaid: ${totalReceived}/${expected}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE block handler ───────────────────────────────────────────────
|
||||
|
||||
async function handleBlockEvent(event: any) {
|
||||
const blockTxs: string[] = event.data?.tx ?? [];
|
||||
if (blockTxs.length === 0) return;
|
||||
|
||||
// Find payment txs in this block
|
||||
const paymentIds = new Set<number>();
|
||||
for (const txid of blockTxs) {
|
||||
const pid = txidToPayment.get(txid);
|
||||
if (pid != null) paymentIds.add(pid);
|
||||
}
|
||||
|
||||
if (paymentIds.size === 0) return;
|
||||
|
||||
// Mark matched txs as confirmed
|
||||
const matchedTxids = blockTxs.filter(t => txidToPayment.has(t));
|
||||
if (matchedTxids.length > 0) {
|
||||
await sql`UPDATE payment_txs SET confirmed = true WHERE txid = ANY(${matchedTxids})`;
|
||||
}
|
||||
|
||||
for (const pid of paymentIds) {
|
||||
const [payment] = await sql`
|
||||
SELECT * FROM payments WHERE id = ${pid} AND status IN ('underpaid', 'confirming')
|
||||
`;
|
||||
if (!payment) continue;
|
||||
|
||||
// Check if all txs for this payment are confirmed
|
||||
const [{ unconfirmed }] = await sql`
|
||||
SELECT COUNT(*)::int as unconfirmed FROM payment_txs
|
||||
WHERE payment_id = ${pid} AND confirmed = false
|
||||
`;
|
||||
|
||||
if (Number(unconfirmed) > 0) continue;
|
||||
|
||||
// All confirmed — check total meets threshold
|
||||
const [{ total }] = await sql`
|
||||
SELECT COALESCE(SUM(amount::numeric), 0) as total FROM payment_txs WHERE payment_id = ${pid}
|
||||
`;
|
||||
const totalReceived = Number(total);
|
||||
const expected = parseFloat(payment.amount_crypto);
|
||||
|
||||
if (totalReceived >= expected * THRESHOLD) {
|
||||
await sql`UPDATE payments SET amount_received = ${totalReceived.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${pid} AND status != 'paid'`;
|
||||
await applyPlan(payment);
|
||||
addressMap.delete(payment.address);
|
||||
console.log(`Payment ${pid} paid (all txs confirmed)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bulk polling fallback ───────────────────────────────────────────
|
||||
// ── Polling fallback ────────────────────────────────────────────────
|
||||
|
||||
export async function checkPayments() {
|
||||
await sql`
|
||||
UPDATE payments SET status = 'expired'
|
||||
WHERE status IN ('pending', 'underpaid', 'confirming')
|
||||
AND expires_at < now()
|
||||
WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at < now()
|
||||
`;
|
||||
|
||||
await refreshMaps();
|
||||
|
||||
const allPayments = await sql`
|
||||
SELECT * FROM payments
|
||||
WHERE status IN ('pending', 'underpaid', 'confirming')
|
||||
AND expires_at >= now()
|
||||
const payments = await sql`
|
||||
SELECT * FROM payments WHERE status IN ('pending', 'underpaid', 'confirming') AND expires_at >= now()
|
||||
`;
|
||||
if (payments.length === 0) return;
|
||||
|
||||
if (allPayments.length === 0) return;
|
||||
|
||||
const addresses = allPayments.map((p: any) => p.address);
|
||||
let bulk: Record<string, any> = {};
|
||||
try {
|
||||
bulk = await getAddressInfoBulk(addresses);
|
||||
} catch (e) {
|
||||
console.error("Bulk address lookup failed, falling back to individual:", e);
|
||||
}
|
||||
try { bulk = await getAddressInfoBulk(payments.map((p: any) => p.address)); } catch {}
|
||||
|
||||
for (const payment of allPayments) {
|
||||
for (const payment of payments) {
|
||||
try {
|
||||
let info = bulk[payment.address];
|
||||
if (!info) {
|
||||
try { info = await getAddressInfo(payment.address); } catch { continue; }
|
||||
}
|
||||
if (!info) try { info = await getAddressInfo(payment.address); } catch { continue; }
|
||||
if (!info || info.error) continue;
|
||||
|
||||
const coin = COINS[payment.coin];
|
||||
if (!coin) continue;
|
||||
|
||||
const received = Number(info.received ?? 0);
|
||||
const receivedConfirmed = Number(info.received_confirmed ?? 0);
|
||||
const expected = parseFloat(payment.amount_crypto);
|
||||
const threshold = expected * THRESHOLD;
|
||||
|
||||
// Sync txs from address info into payment_txs
|
||||
let newTxDetected = false;
|
||||
if (info.in?.length) {
|
||||
for (const tx of info.in) {
|
||||
if (!tx.txid) continue;
|
||||
const [ins] = await sql`
|
||||
INSERT INTO payment_txs (payment_id, txid, amount, confirmed)
|
||||
VALUES (${payment.id}, ${tx.txid}, ${String(tx.amount ?? 0)}, ${tx.block != null})
|
||||
ON CONFLICT (payment_id, txid) DO UPDATE SET confirmed = EXCLUDED.confirmed
|
||||
RETURNING id
|
||||
`;
|
||||
if (ins) newTxDetected = true;
|
||||
}
|
||||
}
|
||||
// Extend expiry to 24h on first tx detection
|
||||
if (newTxDetected) {
|
||||
const newExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await sql`UPDATE payments SET expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.id} AND expires_at < ${newExpiry.toISOString()}`;
|
||||
}
|
||||
|
||||
if (payment.status === "pending" || payment.status === "underpaid") {
|
||||
if (coin.confirmations === 0 && received >= threshold) {
|
||||
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'paid', paid_at = now() WHERE id = ${payment.id} AND status != 'paid'`;
|
||||
await applyPlan(payment);
|
||||
} else if (coin.confirmations > 0 && receivedConfirmed >= threshold) {
|
||||
// Check all txs confirmed
|
||||
const [{ unconfirmed }] = await sql`
|
||||
SELECT COUNT(*)::int as unconfirmed FROM payment_txs WHERE payment_id = ${payment.id} AND confirmed = false
|
||||
`;
|
||||
if (Number(unconfirmed) === 0) {
|
||||
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);
|
||||
}
|
||||
} else if (received >= threshold) {
|
||||
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'confirming' WHERE id = ${payment.id}`;
|
||||
} else if (received > 0) {
|
||||
await sql`UPDATE payments SET amount_received = ${received.toFixed(8)}, status = 'underpaid' WHERE id = ${payment.id}`;
|
||||
}
|
||||
} else if (payment.status === "confirming") {
|
||||
const [{ unconfirmed }] = await sql`
|
||||
SELECT COUNT(*)::int as unconfirmed FROM payment_txs WHERE payment_id = ${payment.id} AND confirmed = false
|
||||
`;
|
||||
if (receivedConfirmed >= threshold && Number(unconfirmed) === 0) {
|
||||
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);
|
||||
}
|
||||
// Sync txs from address API
|
||||
for (const tx of info.in ?? []) {
|
||||
if (!tx.txid) continue;
|
||||
await recordTx(payment.id, tx.txid, Number(tx.amount ?? 0), tx.block != null);
|
||||
}
|
||||
await evaluatePayment(payment.id);
|
||||
} catch (e) {
|
||||
console.error(`Error checking payment ${payment.id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
export function watchPayment(payment: any) {
|
||||
addressMap.set(payment.address, payment);
|
||||
|
|
@ -321,10 +214,10 @@ 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}`;
|
||||
} else {
|
||||
const [account] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}`;
|
||||
const [acc] = 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 expiry = acc.plan_expires_at ? new Date(acc.plan_expires_at) : null;
|
||||
const base = (acc.plan === "pro" && expiry && expiry > now) ? expiry : 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}`;
|
||||
|
|
@ -335,17 +228,11 @@ async function applyPlan(payment: any) {
|
|||
export async function expireProPlans() {
|
||||
const result = await sql`
|
||||
UPDATE accounts SET plan = 'free', plan_expires_at = NULL
|
||||
WHERE plan = 'pro'
|
||||
AND plan_expires_at IS NOT NULL
|
||||
AND plan_expires_at < now()
|
||||
WHERE plan = 'pro' AND plan_expires_at IS NOT NULL AND plan_expires_at < now()
|
||||
`;
|
||||
if (result.count > 0) {
|
||||
console.log(`Downgraded ${result.count} expired pro accounts to free`);
|
||||
}
|
||||
if (result.count > 0) console.log(`Downgraded ${result.count} expired pro accounts`);
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
startSSE();
|
||||
|
|
|
|||
|
|
@ -404,6 +404,14 @@
|
|||
const serverReceived = parseFloat(data.amount_received || '0');
|
||||
if (serverReceived > localReceived) localReceived = serverReceived;
|
||||
|
||||
// Update expiry countdown if server extended it
|
||||
if (data.expires_at) {
|
||||
const newExpiry = new Date(data.expires_at).getTime();
|
||||
clearInterval(countdownInterval);
|
||||
updateCountdown(newExpiry);
|
||||
countdownInterval = setInterval(() => updateCountdown(newExpiry), 1000);
|
||||
}
|
||||
|
||||
updateAmountDisplay({ ...data, amount_received: localReceived.toFixed(8) });
|
||||
applyStatus(data.status, data);
|
||||
} catch {}
|
||||
|
|
|
|||
|
|
@ -503,9 +503,6 @@
|
|||
No credit card
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/dashboard" class="mt-8 block text-center w-full py-3 bg-brand hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Pro -->
|
||||
|
|
@ -531,9 +528,6 @@
|
|||
Priority support
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/dashboard/checkout" class="mt-8 block text-center w-full py-3 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Upgrade to Pro
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Lifetime -->
|
||||
|
|
@ -562,11 +556,15 @@
|
|||
Limited availability
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/dashboard/checkout" class="mt-8 block text-center w-full py-3 bg-yellow-600 hover:bg-yellow-500 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Get Lifetime
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
<a href="/dashboard" class="inline-flex items-center gap-2 px-8 py-3.5 bg-brand hover:bg-blue-500 text-white font-medium rounded-lg transition-colors text-sm">
|
||||
Get Started
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue