feat: crypto payment system with HD wallets, Freedom.st integration, and checkout UI
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user