204 lines
7.7 KiB
TypeScript
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);
|
|
})
|
|
|
|
;
|