diff --git a/.env.example b/.env.example index 2bbfaa0..060a438 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,12 @@ EMAIL_HMAC_KEY=changeme-use-a-random-secret COORDINATOR_URL=http://localhost:3000 MONITOR_TOKEN=changeme-use-a-random-secret RUST_LOG=info + +# Pay app — crypto payments +FREEDOM_API=https://api-v1.freedom.st +XPUB_BTC=xpub... +XPUB_LTC=Ltub... +XPUB_DOGE=dgub... +XPUB_DASH=drkp... +XPUB_BCH=xpub... +XPUB_XEC=xpub... diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts index fab64ad..83b43ae 100644 --- a/apps/api/src/db.ts +++ b/apps/api/src/db.ts @@ -59,6 +59,7 @@ export async function migrate() { await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS run_id TEXT`; await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`; + await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 2fdfaaf..429a63a 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -115,7 +115,7 @@ export const account = new Elysia({ prefix: "/account" }) .use(requireAuth) .get("/settings", async ({ accountId }) => { - const [acc] = await sql`SELECT id, email_hash, plan, created_at FROM accounts WHERE id = ${accountId}`; + const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, created_at FROM accounts WHERE id = ${accountId}`; const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; const [{ count: monitorCount }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`; const limits = getPlanLimits(acc.plan); @@ -123,6 +123,7 @@ export const account = new Elysia({ prefix: "/account" }) account_id: acc.id, has_email: !!acc.email_hash, plan: acc.plan, + plan_expires_at: acc.plan_expires_at, monitor_count: monitorCount, limits, created_at: acc.created_at, diff --git a/apps/pay/package.json b/apps/pay/package.json new file mode 100644 index 0000000..a07c193 --- /dev/null +++ b/apps/pay/package.json @@ -0,0 +1,21 @@ +{ + "name": "@pingql/pay", + "version": "0.1.0", + "scripts": { + "dev": "bun run --hot src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "elysia": "^1.4.27", + "postgres": "^3.4.8", + "@dashevo/dashcore-lib": "^0.25.0", + "bitcore-lib": "^10.10.7", + "bitcore-lib-cash": "^10.10.5", + "bitcore-lib-doge": "^10.9.0", + "bitcore-lib-ltc": "^10.10.5" + }, + "devDependencies": { + "@types/bun": "^1.3.10", + "typescript": "^5.9.3" + } +} diff --git a/apps/pay/src/address.ts b/apps/pay/src/address.ts new file mode 100644 index 0000000..7e0af7c --- /dev/null +++ b/apps/pay/src/address.ts @@ -0,0 +1,100 @@ +/// 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 +import bitcore from "bitcore-lib"; +// @ts-ignore +import bitcoreCash from "bitcore-lib-cash"; +// @ts-ignore +import bitcoreLtc from "bitcore-lib-ltc"; +// @ts-ignore +import bitcoreDoge from "bitcore-lib-doge"; +// @ts-ignore +import dashcore from "@dashevo/dashcore-lib"; + +interface CoinLib { + HDPublicKey: any; +} + +const LIBS: Record = { + 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]; + if (!val) throw new Error(`Missing env var: ${key}`); + 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 { + const xpub = getXpub(coin === "xec" ? "xec" : coin); + + if (coin === "btc") { + const hd = new bitcore.HDPublicKey(xpub); + return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString(); + } + if (coin === "bch") { + const hd = new bitcoreCash.HDPublicKey(xpub); + return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toCashAddress(); + } + if (coin === "xec") { + const hd = new bitcoreCash.HDPublicKey(xpub); + const addr = hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toCashAddress(); + return addr.replace(/^bitcoincash:/, "ecash:"); + } + if (coin === "ltc") { + const hd = new bitcoreLtc.HDPublicKey(xpub); + return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString(); + } + if (coin === "doge") { + const hd = new bitcoreDoge.HDPublicKey(xpub); + return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString(); + } + if (coin === "dash") { + const hd = new dashcore.HDPublicKey(xpub); + return hd.deriveChild(0).deriveChild(index).publicKey.toAddress().toString(); + } + + throw new Error(`Unsupported coin: ${coin}`); +} + +export { deriveAddressSafe as derive }; diff --git a/apps/pay/src/db.ts b/apps/pay/src/db.ts new file mode 100644 index 0000000..ddb1836 --- /dev/null +++ b/apps/pay/src/db.ts @@ -0,0 +1,38 @@ +import postgres from "postgres"; + +const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@localhost:5432/pingql", { + max: 10, + idle_timeout: 30, + connect_timeout: 10, +}); + +export default sql; + +export async function migrate() { + // Plan expiry on accounts (may already exist from API/web migrations) + await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`; + + await sql` + CREATE TABLE IF NOT EXISTS payments ( + id BIGSERIAL PRIMARY KEY, + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + plan TEXT NOT NULL, + months INTEGER, + amount_usd NUMERIC(10,2) NOT NULL, + coin TEXT NOT NULL, + amount_crypto TEXT NOT NULL, + address TEXT NOT NULL, + derivation_index INTEGER NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ DEFAULT now(), + paid_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + txid TEXT + ) + `; + + 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)`; + + console.log("Pay DB ready"); +} diff --git a/apps/pay/src/freedom.ts b/apps/pay/src/freedom.ts new file mode 100644 index 0000000..af8cf5f --- /dev/null +++ b/apps/pay/src/freedom.ts @@ -0,0 +1,28 @@ +const API = process.env.FREEDOM_API ?? "https://api-v1.freedom.st"; + +export async function getChainInfo(): Promise> { + const res = await fetch(`${API}/rpc/info`); + return res.json(); +} + +export async function getExchangeRates(): Promise> { + const res = await fetch(`${API}/invoice/rates`); + return res.json(); +} + +export async function getAddressInfo(address: string): Promise { + const res = await fetch(`${API}/address/${address}`); + 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 { + const info = await getChainInfo(); + return Object.entries(info) + .filter(([_, v]) => v && v.uptime != null) + .map(([k]) => k); +} diff --git a/apps/pay/src/index.ts b/apps/pay/src/index.ts new file mode 100644 index 0000000..14ca2af --- /dev/null +++ b/apps/pay/src/index.ts @@ -0,0 +1,58 @@ +import { Elysia } from "elysia"; +import { migrate } from "./db"; +import { routes } from "./routes"; +import { checkPayments, expireProPlans } from "./monitor"; + +await migrate(); + +const SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains", + "Referrer-Policy": "strict-origin-when-cross-origin", +}; + +const CORS_ORIGIN = process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"]; + +const app = new Elysia() + .onAfterHandle(({ set }) => { + Object.assign(set.headers, SECURITY_HEADERS); + }) + // CORS for web app + .onRequest(({ request, set }) => { + const origin = request.headers.get("origin") ?? ""; + if (CORS_ORIGIN.includes(origin)) { + set.headers["access-control-allow-origin"] = origin; + set.headers["access-control-allow-credentials"] = "true"; + set.headers["access-control-allow-methods"] = "GET, POST, OPTIONS"; + set.headers["access-control-allow-headers"] = "Content-Type, Authorization"; + } + }) + .options("/*", ({ request }) => { + const origin = request.headers.get("origin") ?? ""; + const allowed = CORS_ORIGIN.includes(origin) ? origin : CORS_ORIGIN[0]; + return new Response(null, { + status: 204, + headers: { + "access-control-allow-origin": allowed, + "access-control-allow-credentials": "true", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "Content-Type, Authorization", + }, + }); + }) + .get("/", () => ({ name: "PingQL Pay", version: "1" })) + .use(routes) + .listen(3002); + +console.log(`PingQL Pay running at http://localhost:${app.server?.port}`); + +// Check pending payments every 30 seconds +setInterval(() => { + checkPayments().catch((err) => console.error("Payment check failed:", err)); +}, 30_000); + +// Expire pro plans every hour +setInterval(() => { + expireProPlans().catch((err) => console.error("Plan expiry check failed:", err)); +}, 60 * 60_000); diff --git a/apps/pay/src/monitor.ts b/apps/pay/src/monitor.ts new file mode 100644 index 0000000..bcd71ed --- /dev/null +++ b/apps/pay/src/monitor.ts @@ -0,0 +1,134 @@ +/// Background job: poll pending payments and activate plans on confirmation. +import sql from "./db"; +import { getAddressInfo } from "./freedom"; +import { COINS } from "./plans"; + +/** Check all pending/confirming payments against the blockchain. */ +export async function checkPayments() { + // Expire stale payments + await sql` + UPDATE payments SET status = 'expired' + WHERE status IN ('pending', 'confirming') + AND expires_at < now() + `; + + const pending = await sql` + SELECT * FROM payments + WHERE status IN ('pending', 'confirming') + AND expires_at >= now() + `; + + for (const payment of pending) { + try { + await checkPayment(payment); + } catch (e) { + console.error(`Error checking payment ${payment.id}:`, e); + } + } +} + +async function checkPayment(payment: any) { + const info = await getAddressInfo(payment.address); + if (info.error) return; + + const coin = COINS[payment.coin]; + if (!coin) return; + + const expectedSats = cryptoToSats(payment.coin, payment.amount_crypto); + + // Sum confirmed received + const confirmedReceived = sumReceived(info, true); + // Sum all received (including unconfirmed) + const totalReceived = sumReceived(info, false); + + // Allow 0.5% tolerance for rounding + const threshold = expectedSats * 0.995; + + if (coin.confirmations === 0) { + // 0-conf coins (BCH, XEC): accept as soon as seen in mempool + if (totalReceived >= threshold) { + const txid = findTxid(info); + await activatePayment(payment, txid); + } + } else { + // 1-conf coins: need confirmed balance + if (confirmedReceived >= threshold) { + const txid = findTxid(info); + await activatePayment(payment, txid); + } else if (totalReceived >= threshold && payment.status === "pending") { + // Seen in mempool but not yet confirmed + await sql`UPDATE payments SET status = 'confirming' WHERE id = ${payment.id}`; + } + } +} + +function sumReceived(info: any, confirmedOnly: boolean): number { + // Freedom.st /address response has balance fields + // The response includes received/balance in the coin's base unit (satoshis) + if (confirmedOnly) { + return Number(info.balance?.confirmed ?? info.confirmed ?? info.received ?? 0); + } + // Total = confirmed + unconfirmed + const confirmed = Number(info.balance?.confirmed ?? info.confirmed ?? info.received ?? 0); + const unconfirmed = Number(info.balance?.unconfirmed ?? info.unconfirmed ?? 0); + return confirmed + unconfirmed; +} + +function findTxid(info: any): string | null { + // Try to get the first txid from transaction history + if (info.txs?.length) return info.txs[0].txid || info.txs[0].hash || null; + if (info.transactions?.length) return info.transactions[0].txid || info.transactions[0] || null; + return null; +} + +/** Convert a decimal crypto amount string to satoshis/base units. */ +function cryptoToSats(coin: string, amount: string): number { + // XEC uses 100 sats per coin, everything else uses 1e8 + const multiplier = coin === "xec" ? 100 : 1e8; + return Math.round(parseFloat(amount) * multiplier); +} + +async function activatePayment(payment: any, txid: string | null) { + await sql` + UPDATE payments + SET status = 'paid', paid_at = now(), txid = ${txid} + WHERE id = ${payment.id} AND status != 'paid' + `; + + if (payment.plan === "lifetime") { + await sql` + UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL + WHERE id = ${payment.account_id} + `; + } else { + // Pro: extend from current expiry or now + 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} + `; + } + + console.log(`Payment ${payment.id} activated: ${payment.plan} for account ${payment.account_id}`); +} + +/** Downgrade expired pro accounts back to free. */ +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() + `; + if (result.count > 0) { + console.log(`Downgraded ${result.count} expired pro accounts to free`); + } +} diff --git a/apps/pay/src/plans.ts b/apps/pay/src/plans.ts new file mode 100644 index 0000000..ce1cc17 --- /dev/null +++ b/apps/pay/src/plans.ts @@ -0,0 +1,19 @@ +export const PLANS = { + pro: { + label: "Pro", + monthlyUsd: 14, + }, + lifetime: { + label: "Lifetime", + priceUsd: 149, + }, +} as const; + +export const COINS: Record = { + btc: { label: "Bitcoin", ticker: "BTC", confirmations: 1, uri: "bitcoin" }, + ltc: { label: "Litecoin", ticker: "LTC", confirmations: 1, uri: "litecoin" }, + doge: { label: "Dogecoin", ticker: "DOGE", confirmations: 1, uri: "dogecoin" }, + dash: { label: "Dash", ticker: "DASH", confirmations: 1, uri: "dash" }, + bch: { label: "Bitcoin Cash", ticker: "BCH", confirmations: 0, uri: "bitcoincash" }, + xec: { label: "eCash", ticker: "XEC", confirmations: 0, uri: "ecash" }, +}; diff --git a/apps/pay/src/routes.ts b/apps/pay/src/routes.ts new file mode 100644 index 0000000..26172fc --- /dev/null +++ b/apps/pay/src/routes.ts @@ -0,0 +1,165 @@ +import { Elysia, t } from "elysia"; +import sql from "./db"; +import { derive } from "./address"; +import { getExchangeRates, getAvailableCoins, getQrUrl } from "./freedom"; +import { PLANS, COINS } from "./plans"; + +// 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 amountUsd = plan === "lifetime" ? PLANS.lifetime.priceUsd : PLANS.pro.monthlyUsd * (months ?? 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 * + `; + + // 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, + address: payment.address, + status: payment.status, + expires_at: payment.expires_at, + qr_url: getQrUrl(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 uri = `${coinInfo.uri}:${payment.address}?amount=${payment.amount_crypto}`; + + return { + id: payment.id, + plan: payment.plan, + months: payment.months, + amount_usd: Number(payment.amount_usd), + coin: payment.coin, + amount_crypto: payment.amount_crypto, + address: payment.address, + status: payment.status, + created_at: payment.created_at, + expires_at: payment.expires_at, + paid_at: payment.paid_at, + txid: payment.txid, + qr_url: getQrUrl(uri), + coin_label: coinInfo?.label, + coin_ticker: coinInfo?.ticker, + }; + }) + + // Lightweight status poll + .get("/checkout/:id/status", async ({ accountId, params, set }) => { + const [payment] = await sql` + SELECT status, txid FROM payments WHERE id = ${params.id} AND account_id = ${accountId} + `; + if (!payment) { set.status = 404; return { error: "Payment not found" }; } + return { status: payment.status, txid: payment.txid }; + }); diff --git a/apps/pay/tsconfig.json b/apps/pay/tsconfig.json new file mode 100644 index 0000000..07f2554 --- /dev/null +++ b/apps/pay/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "types": ["bun-types"] + }, + "include": ["src"] +} diff --git a/apps/web/src/db.ts b/apps/web/src/db.ts index 7a9d087..3652634 100644 --- a/apps/web/src/db.ts +++ b/apps/web/src/db.ts @@ -56,6 +56,7 @@ export async function migrate() { await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`; await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`; + await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`; diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 895d7e8..bf68a1c 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -198,7 +198,7 @@ export const dashboard = new Elysia() const keyId = resolved?.keyId ?? null; if (!accountId) return redirect("/dashboard"); - const [acc] = await sql`SELECT id, email_hash, plan, created_at FROM accounts WHERE id = ${accountId}`; + const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, created_at FROM accounts WHERE id = ${accountId}`; const isSubKey = !!keyId; const apiKeys = isSubKey ? [] : await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null); @@ -207,6 +207,15 @@ export const dashboard = new Elysia() return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey, monitorCount }); }) + // Checkout — upgrade plan + .get("/dashboard/checkout", async ({ cookie, headers }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${resolved.accountId}`; + if (acc.plan === "lifetime") return redirect("/dashboard/settings"); + return html("checkout", { nav: "settings", account: acc, payApi: process.env.PAY_API || "https://pay.pingql.com" }); + }) + // New monitor .get("/dashboard/monitors/new", async ({ cookie, headers }) => { const resolved = await getAccountId(cookie, headers); diff --git a/apps/web/src/views/checkout.ejs b/apps/web/src/views/checkout.ejs new file mode 100644 index 0000000..15bb794 --- /dev/null +++ b/apps/web/src/views/checkout.ejs @@ -0,0 +1,298 @@ +<%~ include('./partials/head', { title: 'Upgrade' }) %> +<%~ include('./partials/nav', { nav: 'settings' }) %> + +<% + const plan = it.account.plan; + const expiresAt = it.account.plan_expires_at; +%> + +
+
+ ← Back to settings +

Upgrade Plan

+
+ + +
+ + +
+ <% if (plan !== 'lifetime') { %> + + <% } %> + <% if (plan !== 'lifetime') { %> + + <% } %> +
+ + + + + + + + + + + + +
+ + + + +
+ + + +<%~ include('./partials/foot') %> diff --git a/apps/web/src/views/landing.ejs b/apps/web/src/views/landing.ejs index f541182..fcc4a27 100644 --- a/apps/web/src/views/landing.ejs +++ b/apps/web/src/views/landing.ejs @@ -509,68 +509,62 @@ -
-
- coming soon -
+
Pro
-
$14
-
per month
-
    +
    $14
    +
    per month
    +
    • - + 500 monitors
    • - + 2s check interval
    • - + Webhook notifications
    • - + Priority support
    -
    - Coming Soon -
    + + Upgrade to Pro +
-
- coming soon -
Lifetime
-
$149
+
$149
$179
-
one-time
-
    +
    one-time
    +
    • - + Everything in Pro
    • - + Pay once, use forever
    • - + All future updates
    • - + Limited availability
    -
    - Coming Soon -
    + + Get Lifetime +
diff --git a/apps/web/src/views/settings.ejs b/apps/web/src/views/settings.ejs index 00dabdd..e76f062 100644 --- a/apps/web/src/views/settings.ejs +++ b/apps/web/src/views/settings.ejs @@ -35,8 +35,13 @@ <% if (plan === 'free') { %> + + <% } else if (plan === 'pro' && it.account.plan_expires_at) { %>
- Upgrade to Pro for 500 monitors and 2s intervals. Coming soon. + Pro plan expires <%= new Date(it.account.plan_expires_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %>. + Extend or upgrade to Lifetime
<% } %> diff --git a/deploy.sh b/deploy.sh index 38cc015..8336330 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,6 +1,6 @@ #!/bin/bash # PingQL Deploy Script -# Usage: ./deploy.sh [web|api|monitor|db|nuke-db|all] [...] +# Usage: ./deploy.sh [web|api|pay|monitor|db|nuke-db|all] [...] # Example: ./deploy.sh web api # Example: ./deploy.sh all # Example: ./deploy.sh nuke-db (wipes all data — NOT included in "all") @@ -29,7 +29,7 @@ nuke_db() { fi echo "[nuke-db] Dropping all tables on database-eu-central..." $SSH $DB_HOST bash << 'REMOTE' - sudo -u postgres psql -d pingql -c "DROP TABLE IF EXISTS pings, api_keys, monitors, accounts CASCADE;" + sudo -u postgres psql -d pingql -c "DROP TABLE IF EXISTS payments, pings, api_keys, monitors, accounts CASCADE;" echo "All tables dropped" REMOTE echo "[nuke-db] Done. Tables will be recreated on next API/web restart." @@ -48,6 +48,19 @@ deploy_api() { REMOTE } +deploy_pay() { + echo "[pay] Deploying to api-eu-central..." + $SSH $API_HOST bash << 'REMOTE' + cd /opt/pingql + git pull + cd apps/pay + /root/.bun/bin/bun install + systemctl restart pingql-pay + systemctl restart caddy + echo "Pay deployed and restarted" +REMOTE +} + deploy_web() { echo "[web] Deploying to web-eu-central..." $SSH $WEB_HOST bash << 'REMOTE' @@ -83,7 +96,7 @@ REMOTE # Parse args if [ $# -eq 0 ]; then - echo "Usage: $0 [web|api|monitor|db|all] [...]" + echo "Usage: $0 [web|api|pay|monitor|db|all] [...]" exit 1 fi @@ -91,11 +104,12 @@ for arg in "$@"; do case "$arg" in db) deploy_db ;; api) deploy_api ;; + pay) deploy_pay ;; web) deploy_web ;; monitor) deploy_monitor ;; nuke-db) nuke_db ;; - all) deploy_db; deploy_api; deploy_web; deploy_monitor ;; - *) echo "Unknown target: $arg (valid: web, api, monitor, db, nuke-db, all)"; exit 1 ;; + all) deploy_db; deploy_api; deploy_pay; deploy_web; deploy_monitor ;; + *) echo "Unknown target: $arg (valid: web, api, pay, monitor, db, nuke-db, all)"; exit 1 ;; esac done