feat: crypto payment system with HD wallets, Freedom.st integration, and checkout UI

This commit is contained in:
nate 2026-03-18 23:04:17 +04:00
parent bead49b870
commit c9130243e8
18 changed files with 943 additions and 35 deletions

View File

@ -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...

View File

@ -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)`;

View File

@ -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,

21
apps/pay/package.json Normal file
View File

@ -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"
}
}

100
apps/pay/src/address.ts Normal file
View File

@ -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<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];
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 };

38
apps/pay/src/db.ts Normal file
View File

@ -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");
}

28
apps/pay/src/freedom.ts Normal file
View File

@ -0,0 +1,28 @@
const API = process.env.FREEDOM_API ?? "https://api-v1.freedom.st";
export async function getChainInfo(): Promise<Record<string, any>> {
const res = await fetch(`${API}/rpc/info`);
return res.json();
}
export async function getExchangeRates(): Promise<Record<string, number>> {
const res = await fetch(`${API}/invoice/rates`);
return res.json();
}
export async function getAddressInfo(address: string): Promise<any> {
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<string[]> {
const info = await getChainInfo();
return Object.entries(info)
.filter(([_, v]) => v && v.uptime != null)
.map(([k]) => k);
}

58
apps/pay/src/index.ts Normal file
View File

@ -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);

134
apps/pay/src/monitor.ts Normal file
View File

@ -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`);
}
}

19
apps/pay/src/plans.ts Normal file
View File

@ -0,0 +1,19 @@
export const PLANS = {
pro: {
label: "Pro",
monthlyUsd: 14,
},
lifetime: {
label: "Lifetime",
priceUsd: 149,
},
} as const;
export const COINS: Record<string, { label: string; ticker: string; confirmations: number; uri: string }> = {
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" },
};

165
apps/pay/src/routes.ts Normal file
View File

@ -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 };
});

13
apps/pay/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"types": ["bun-types"]
},
"include": ["src"]
}

View File

@ -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)`;

View File

@ -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);

View File

@ -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;
%>
<main class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6">
<a href="/dashboard/settings" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">&larr; Back to settings</a>
<h2 class="text-lg font-semibold text-gray-200 mt-2">Upgrade Plan</h2>
</div>
<!-- Step 1: Plan & coin selection -->
<div id="step-select" class="space-y-6">
<!-- Plan cards -->
<div class="grid grid-cols-2 gap-4">
<% if (plan !== 'lifetime') { %>
<button onclick="selectPlan('pro')" id="plan-pro"
class="plan-card text-left bg-gray-900 border-2 border-gray-800 hover:border-blue-500/50 rounded-xl p-5 transition-colors">
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-1">Pro</div>
<div class="text-2xl font-bold text-gray-100">$14<span class="text-sm font-normal text-gray-500">/mo</span></div>
<div class="text-xs text-gray-500 mt-2">500 monitors, 2s intervals</div>
</button>
<% } %>
<% if (plan !== 'lifetime') { %>
<button onclick="selectPlan('lifetime')" id="plan-lifetime"
class="plan-card text-left bg-gray-900 border-2 border-gray-800 hover:border-yellow-500/50 rounded-xl p-5 transition-colors">
<div class="text-xs text-yellow-500/70 uppercase tracking-wider font-mono mb-1">Lifetime</div>
<div class="text-2xl font-bold text-gray-100">$149</div>
<div class="text-xs text-gray-500 mt-2">One-time, forever</div>
</button>
<% } %>
</div>
<!-- Months selector (shown for pro) -->
<div id="months-section" class="hidden">
<label class="block text-sm text-gray-400 mb-2">How many months?</label>
<div class="flex items-center gap-4">
<input type="range" id="months-range" min="1" max="12" value="1"
class="flex-1 accent-blue-500" oninput="updateMonths(this.value)">
<div class="text-right" style="min-width:80px">
<span id="months-display" class="text-lg font-semibold text-gray-100">1</span>
<span class="text-sm text-gray-500"> month</span>
</div>
</div>
<div class="text-right mt-1">
<span class="text-sm text-gray-400">Total: $<span id="total-display">14</span></span>
</div>
</div>
<!-- Coin selector -->
<div id="coin-section" class="hidden">
<label class="block text-sm text-gray-400 mb-2">Pay with</label>
<div id="coin-grid" class="grid grid-cols-3 gap-2">
<!-- Populated by JS -->
</div>
</div>
<!-- Error -->
<div id="select-error" class="text-red-400 text-sm hidden"></div>
<!-- Continue button -->
<button id="continue-btn" onclick="createCheckout()" disabled
class="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-gray-800 disabled:text-gray-600 text-white font-medium py-3 rounded-lg transition-colors hidden">
Continue to Payment
</button>
</div>
<!-- Step 2: Payment -->
<div id="step-pay" class="hidden">
<div class="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center space-y-5">
<!-- QR -->
<div>
<img id="pay-qr" src="" alt="QR Code" class="w-48 h-48 mx-auto rounded-lg bg-white p-2">
</div>
<!-- Amount -->
<div>
<div class="text-2xl font-bold font-mono text-gray-100" id="pay-amount"></div>
<div class="text-sm text-gray-500" id="pay-coin-label"></div>
<div class="text-xs text-gray-600 mt-1">$<span id="pay-usd"></span> USD</div>
</div>
<!-- Address -->
<div>
<label class="block text-xs text-gray-500 mb-1">Send to</label>
<div class="flex gap-2">
<code id="pay-address" class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-blue-400 text-xs font-mono select-all break-all"></code>
<button onclick="copyAddress()" id="copy-btn" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors shrink-0">Copy</button>
</div>
</div>
<!-- Status -->
<div id="pay-status-section">
<div id="pay-status" class="flex items-center justify-center gap-2 text-sm">
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
<span class="text-gray-400">Waiting for payment...</span>
</div>
<div class="text-xs text-gray-600 mt-2">
Expires in <span id="pay-countdown" class="font-mono"></span>
</div>
</div>
<!-- Success -->
<div id="pay-success" class="hidden">
<div class="flex items-center justify-center gap-2 text-green-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<span class="font-medium">Payment confirmed!</span>
</div>
<p class="text-xs text-gray-500 mt-2">Redirecting to settings...</p>
</div>
<!-- Expired -->
<div id="pay-expired" class="hidden">
<div class="flex items-center justify-center gap-2 text-red-400">
<span class="font-medium">Payment expired</span>
</div>
<button onclick="resetCheckout()" class="mt-3 text-sm text-blue-400 hover:text-blue-300">Try again</button>
</div>
</div>
</div>
</main>
<script>
const PAY_API = '<%= it.payApi || "" %>';
let selectedPlan = null;
let selectedCoin = null;
let selectedMonths = 1;
let coins = [];
let paymentId = null;
let pollInterval = null;
let countdownInterval = null;
// Fetch available coins on load
(async () => {
try {
const res = await fetch(`${PAY_API}/coins`, { credentials: 'include' });
const data = await res.json();
coins = data.coins;
} catch (e) {
console.error('Failed to fetch coins:', e);
}
})();
function selectPlan(plan) {
selectedPlan = plan;
document.querySelectorAll('.plan-card').forEach(el => el.classList.remove('border-blue-500', 'border-yellow-500'));
const card = document.getElementById(`plan-${plan}`);
card.classList.add(plan === 'lifetime' ? 'border-yellow-500' : 'border-blue-500');
const monthsSection = document.getElementById('months-section');
monthsSection.classList.toggle('hidden', plan !== 'pro');
showCoins();
}
function updateMonths(val) {
selectedMonths = Number(val);
document.getElementById('months-display').textContent = val;
document.getElementById('total-display').textContent = (14 * selectedMonths).toString();
document.querySelector('#months-section .text-sm.text-gray-500').textContent = selectedMonths === 1 ? ' month' : ' months';
}
function showCoins() {
const grid = document.getElementById('coin-grid');
grid.innerHTML = coins.map(c => `
<button onclick="selectCoin('${c.id}')" id="coin-${c.id}"
class="coin-btn flex items-center gap-2 bg-gray-800 border-2 border-gray-700 hover:border-gray-500 rounded-lg px-3 py-2.5 transition-colors text-left">
<span class="text-sm font-medium text-gray-300">${c.ticker}</span>
<span class="text-xs text-gray-600">${c.label}</span>
</button>
`).join('');
document.getElementById('coin-section').classList.remove('hidden');
document.getElementById('continue-btn').classList.remove('hidden');
selectedCoin = null;
document.getElementById('continue-btn').disabled = true;
}
function selectCoin(coinId) {
selectedCoin = coinId;
document.querySelectorAll('.coin-btn').forEach(el => el.classList.remove('border-blue-500'));
document.getElementById(`coin-${coinId}`).classList.add('border-blue-500');
document.getElementById('continue-btn').disabled = false;
}
async function createCheckout() {
const btn = document.getElementById('continue-btn');
const errEl = document.getElementById('select-error');
errEl.classList.add('hidden');
btn.disabled = true;
btn.textContent = 'Creating...';
try {
const body = { plan: selectedPlan, coin: selectedCoin };
if (selectedPlan === 'pro') body.months = selectedMonths;
const res = await fetch(`${PAY_API}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Checkout failed');
paymentId = data.id;
showPayment(data);
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'Continue to Payment';
}
}
function showPayment(data) {
document.getElementById('step-select').classList.add('hidden');
document.getElementById('step-pay').classList.remove('hidden');
document.getElementById('pay-qr').src = data.qr_url;
document.getElementById('pay-amount').textContent = data.amount_crypto;
document.getElementById('pay-coin-label').textContent = data.coin_label + ' (' + data.coin_ticker + ')';
document.getElementById('pay-usd').textContent = data.amount_usd.toFixed(2);
document.getElementById('pay-address').textContent = data.address;
// Start countdown
const expiresAt = new Date(data.expires_at).getTime();
updateCountdown(expiresAt);
countdownInterval = setInterval(() => updateCountdown(expiresAt), 1000);
// Start polling
pollInterval = setInterval(() => pollStatus(), 5000);
}
function updateCountdown(expiresAt) {
const remaining = Math.max(0, expiresAt - Date.now());
const mins = Math.floor(remaining / 60000);
const secs = Math.floor((remaining % 60000) / 1000);
document.getElementById('pay-countdown').textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
if (remaining <= 0) {
clearInterval(countdownInterval);
}
}
async function pollStatus() {
try {
const res = await fetch(`${PAY_API}/checkout/${paymentId}/status`, { 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>
`;
} else if (data.status === 'paid') {
clearInterval(pollInterval);
clearInterval(countdownInterval);
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);
document.getElementById('pay-status-section').classList.add('hidden');
document.getElementById('pay-expired').classList.remove('hidden');
}
} catch {}
}
function copyAddress() {
navigator.clipboard.writeText(document.getElementById('pay-address').textContent);
const btn = document.getElementById('copy-btn');
btn.textContent = 'Copied!';
btn.classList.add('text-green-400');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
}
function resetCheckout() {
document.getElementById('step-select').classList.remove('hidden');
document.getElementById('step-pay').classList.add('hidden');
document.getElementById('pay-success').classList.add('hidden');
document.getElementById('pay-expired').classList.add('hidden');
document.getElementById('pay-status-section').classList.remove('hidden');
document.getElementById('pay-status').innerHTML = `
<span class="w-2 h-2 rounded-full bg-yellow-500 animate-pulse"></span>
<span class="text-gray-400">Waiting for payment...</span>
`;
paymentId = null;
}
</script>
<%~ include('./partials/foot') %>

View File

@ -509,68 +509,62 @@
</div>
<!-- Pro -->
<div class="rounded-xl border border-gray-700 bg-[#111] p-8 text-left relative">
<div class="absolute -top-3 right-6 px-3 py-1 bg-gray-800 border border-gray-700 rounded-full text-xs text-gray-400 font-mono">
coming soon
</div>
<div class="rounded-xl border border-blue-500/30 bg-[#111] p-8 text-left relative">
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Pro</div>
<div class="text-4xl font-bold mb-1 text-gray-500">$14</div>
<div class="text-sm text-gray-600 mb-6">per month</div>
<ul class="space-y-3 text-sm text-gray-500">
<div class="text-4xl font-bold mb-1">$14</div>
<div class="text-sm text-gray-500 mb-6">per month</div>
<ul class="space-y-3 text-sm text-gray-400">
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
500 monitors
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
2s check interval
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Webhook notifications
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Priority support
</li>
</ul>
<div class="mt-8 block text-center w-full py-3 border border-gray-700 text-gray-500 text-sm font-medium rounded-lg cursor-default">
Coming Soon
</div>
<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 -->
<div class="rounded-xl border border-yellow-500/30 bg-[#111] p-8 text-left relative">
<div class="absolute -top-3 right-6 px-3 py-1 bg-yellow-900/40 border border-yellow-500/30 rounded-full text-xs text-yellow-400 font-mono">
coming soon
</div>
<div class="text-xs text-yellow-500/70 uppercase tracking-wider font-mono mb-2">Lifetime</div>
<div class="flex items-baseline gap-3 mb-1">
<div class="text-4xl font-bold text-gray-400">$149</div>
<div class="text-4xl font-bold">$149</div>
<div class="text-xl text-gray-600 line-through">$179</div>
</div>
<div class="text-sm text-gray-600 mb-6">one-time</div>
<ul class="space-y-3 text-sm text-gray-500">
<div class="text-sm text-gray-500 mb-6">one-time</div>
<ul class="space-y-3 text-sm text-gray-400">
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg class="w-4 h-4 text-yellow-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Everything in Pro
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg class="w-4 h-4 text-yellow-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Pay once, use forever
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg class="w-4 h-4 text-yellow-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
All future updates
</li>
<li class="flex items-center gap-2">
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<svg class="w-4 h-4 text-yellow-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Limited availability
</li>
</ul>
<div class="mt-8 block text-center w-full py-3 border border-yellow-500/20 text-yellow-500/50 text-sm font-medium rounded-lg cursor-default">
Coming Soon
</div>
<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>

View File

@ -35,8 +35,13 @@
</div>
</div>
<% if (plan === 'free') { %>
<div class="mt-4 pt-4 border-t border-gray-800">
<a href="/dashboard/checkout" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors">Upgrade to Pro</a>
</div>
<% } else if (plan === 'pro' && it.account.plan_expires_at) { %>
<div class="mt-4 pt-4 border-t border-gray-800 text-xs text-gray-500">
Upgrade to Pro for 500 monitors and 2s intervals. <span class="text-gray-600">Coming soon.</span>
Pro plan expires <%= new Date(it.account.plan_expires_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %>.
<a href="/dashboard/checkout" class="text-blue-400 hover:text-blue-300 ml-1">Extend or upgrade to Lifetime</a>
</div>
<% } %>
</section>

View File

@ -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