feat: crypto payment system with HD wallets, Freedom.st integration, and checkout UI
This commit is contained in:
parent
bead49b870
commit
c9130243e8
|
|
@ -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...
|
||||
|
|
|
|||
|
|
@ -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)`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" },
|
||||
};
|
||||
|
|
@ -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 };
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -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)`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">← 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') %>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
24
deploy.sh
24
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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue