pingql/apps/pay/src/routes.ts

204 lines
7.7 KiB
TypeScript

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