import { Elysia, t } from "elysia"; import sql from "./db"; import { derive } from "./address"; import { getExchangeRates, getAvailableCoins, fetchQrBase64 } from "./freedom"; import { PLANS, COINS } from "./plans"; import { generateReceipt } from "./receipt"; import { watchPayment } from "./monitor"; // Resolve account from key (same logic as API/web apps) async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null; plan: string } | null> { const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`; if (account) return { accountId: account.id, keyId: null, plan: account.plan }; const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`; if (apiKey) return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan }; return null; } function requireAuth(app: Elysia) { return app .derive(async ({ headers, cookie, set }) => { const authHeader = headers["authorization"] ?? ""; const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); const cookieKey = cookie?.pingql_key?.value; const key = bearer || cookieKey; if (!key) { set.status = 401; return { accountId: null as string | null, keyId: null as string | null, plan: "free" }; } const resolved = await resolveKey(key); if (resolved) return resolved; set.status = 401; return { accountId: null as string | null, keyId: null as string | null, plan: "free" }; }) .onBeforeHandle(({ accountId, set }) => { if (!accountId) { set.status = 401; return { error: "Unauthorized" }; } }); } export const routes = new Elysia() // Public: available coins and rates .get("/coins", async () => { const [available, rates] = await Promise.all([getAvailableCoins(), getExchangeRates()]); const coins = Object.entries(COINS) .filter(([k]) => available.includes(k)) .map(([k, v]) => ({ id: k, ...v, rate: rates[k] })); return { coins, plans: PLANS }; }) .use(requireAuth) // Create a checkout .post("/checkout", async ({ accountId, keyId, body, set }) => { if (keyId) { set.status = 403; return { error: "Sub-keys cannot create checkouts" }; } const { plan, months, coin } = body; // Validate plan if (plan === "lifetime") { const [acc] = await sql`SELECT plan FROM accounts WHERE id = ${accountId}`; if (acc.plan === "lifetime") { set.status = 400; return { error: "Already on lifetime plan" }; } } // Validate coin if (!COINS[coin]) { set.status = 400; return { error: `Unknown coin: ${coin}` }; } const available = await getAvailableCoins(); if (!available.includes(coin)) { set.status = 400; return { error: `${coin} is temporarily unavailable` }; } // Calculate amount const planDef = PLANS[plan]; if (!planDef) { set.status = 400; return { error: `Unknown plan: ${plan}` }; } let amountUsd = planDef.priceUsd ?? (planDef.monthlyUsd! * (months ?? 1)); // Lifetime discount: credit up to 50% of lifetime price from previous payments if (plan === "lifetime" && planDef.priceUsd) { const [{ total }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total FROM payments WHERE account_id = ${accountId} AND status = 'paid'`; const credit = Math.min(Number(total), planDef.priceUsd * 0.5); amountUsd = Math.max(amountUsd - credit, 1); } const rates = await getExchangeRates(); const rate = rates[coin]; if (!rate) { set.status = 500; return { error: "Could not fetch exchange rate" }; } // Crypto amount with 8 decimal precision const amountCrypto = (amountUsd / rate).toFixed(8); // Get next derivation index const [{ next_index }] = await sql` SELECT COALESCE(MAX(derivation_index), -1) + 1 as next_index FROM payments `; // Derive address let address: string; try { address = derive(coin, next_index); } catch (e: any) { set.status = 500; return { error: `Address derivation failed: ${e.message}` }; } const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour const [payment] = await sql` INSERT INTO payments (account_id, plan, months, amount_usd, coin, amount_crypto, address, derivation_index, expires_at) VALUES (${accountId}, ${plan}, ${plan === "lifetime" ? null : months ?? 1}, ${amountUsd}, ${coin}, ${amountCrypto}, ${address}, ${next_index}, ${expiresAt.toISOString()}) RETURNING * `; // Start watching this address immediately via SSE watchPayment(payment); // Build payment URI for QR code const coinInfo = COINS[coin]; const uri = `${coinInfo.uri}:${address}?amount=${amountCrypto}`; return { id: payment.id, plan: payment.plan, months: payment.months, 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, txs: [], qr_url: await fetchQrBase64(uri), pay_uri: uri, coin_label: coinInfo.label, coin_ticker: coinInfo.ticker, }; }, { body: t.Object({ plan: t.String({ description: "'pro' or 'lifetime'" }), months: t.Optional(t.Number({ minimum: 1, maximum: 12 })), coin: t.String({ description: "Coin ticker: btc, bch, ltc, doge, dash, xec" }), }), }) // Get checkout details .get("/checkout/:id", async ({ accountId, params, set }) => { const [payment] = await sql` SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId} `; if (!payment) { set.status = 404; return { error: "Payment not found" }; } const coinInfo = COINS[payment.coin]; const amountCrypto = parseFloat(payment.amount_crypto); const amountReceived = parseFloat(payment.amount_received || "0"); const amountRemaining = Math.max(0, amountCrypto - amountReceived); const qrAmount = amountRemaining > 0 && amountRemaining < amountCrypto ? amountRemaining.toFixed(8) : payment.amount_crypto; const uri = `${coinInfo.uri}:${payment.address}?amount=${qrAmount}`; const txs = await sql` SELECT txid, amount, confirmed, detected_at FROM payment_txs WHERE payment_id = ${payment.id} ORDER BY detected_at ASC `; return { id: payment.id, plan: payment.plan, months: payment.months, 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, expires_at: payment.expires_at, paid_at: payment.paid_at, txs: txs.map((t: any) => ({ txid: t.txid, amount: t.amount, confirmed: t.confirmed, detected_at: t.detected_at })), qr_url: await fetchQrBase64(uri), pay_uri: uri, coin_label: coinInfo?.label, coin_ticker: coinInfo?.ticker, }; }) // Serve locked receipt for a paid invoice .get("/checkout/:id/receipt", async ({ accountId, params, set }) => { const [payment] = await sql` SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId} `; if (!payment) { set.status = 404; return { error: "Payment not found" }; } if (payment.status !== "paid") { set.status = 400; return { error: "Receipt is only available for paid invoices" }; } set.headers["content-type"] = "text/html; charset=utf-8"; return payment.receipt_html || await generateReceipt(payment.id); }) ;