From 113c1101c442e9032a2b57a9acf0e00555dcf0fe Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 19 Mar 2026 13:40:17 +0400 Subject: [PATCH] feat: add receipts to the payment service --- apps/pay/src/db.ts | 2 + apps/pay/src/monitor.ts | 2 +- apps/pay/src/routes.ts | 133 +++++++++++++++++++++++++++++++ apps/web/src/routes/dashboard.ts | 24 ++++++ apps/web/src/views/checkout.ejs | 5 +- apps/web/src/views/settings.ejs | 67 ++++++++-------- 6 files changed, 198 insertions(+), 35 deletions(-) diff --git a/apps/pay/src/db.ts b/apps/pay/src/db.ts index 879fcd9..24cf074 100644 --- a/apps/pay/src/db.ts +++ b/apps/pay/src/db.ts @@ -50,5 +50,7 @@ export async function migrate() { 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)`; + await sql`ALTER TABLE payments ADD COLUMN IF NOT EXISTS receipt_html TEXT`; + console.log("Pay DB ready"); } diff --git a/apps/pay/src/monitor.ts b/apps/pay/src/monitor.ts index b954c3e..171efec 100644 --- a/apps/pay/src/monitor.ts +++ b/apps/pay/src/monitor.ts @@ -5,7 +5,7 @@ import { getAddressInfo, getAddressInfoBulk } from "./freedom"; import { COINS } from "./plans"; const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st"; -const THRESHOLD = 0.995; +const THRESHOLD = 0.95; // ── In-memory lookups for SSE matching ────────────────────────────── let addressMap = new Map(); // address → payment diff --git a/apps/pay/src/routes.ts b/apps/pay/src/routes.ts index eb3ba7e..aec489a 100644 --- a/apps/pay/src/routes.ts +++ b/apps/pay/src/routes.ts @@ -178,4 +178,137 @@ export const routes = new Elysia() }; }) + // Generate or serve a 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" }; } + + // If receipt already generated, serve it as-is (locked) + if (payment.receipt_html) { + set.headers["content-type"] = "text/html; charset=utf-8"; + return payment.receipt_html; + } + + // Generate receipt + const coinInfo = COINS[payment.coin]; + const txs = await sql` + SELECT txid, amount, confirmed, detected_at + FROM payment_txs WHERE payment_id = ${payment.id} + ORDER BY detected_at ASC + `; + + const paidDate = payment.paid_at + ? new Date(payment.paid_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }) + : "—"; + const createdDate = new Date(payment.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); + const planLabel = payment.plan === "lifetime" ? "Lifetime" : `Pro × ${payment.months} month${payment.months > 1 ? "s" : ""}`; + + const txRows = txs.map((tx: any) => { + const date = new Date(tx.detected_at).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + return ` + ${tx.txid} + ${tx.amount} ${coinInfo?.ticker || payment.coin.toUpperCase()} + ${date} + `; + }).join(""); + + const receiptHtml = ` + + + + + PingQL Receipt #${payment.id} + + + +
+ +
+ +
+ +
Receipt #${payment.id} · Issued ${paidDate}
+
+ +
+
Plan
+
+ Product + PingQL ${planLabel} +
+
+ Invoice Date + ${createdDate} +
+
+ Payment Date + ${paidDate} +
+
+ +
+
Payment
+
+ Currency + ${coinInfo?.label || payment.coin} (${coinInfo?.ticker || payment.coin.toUpperCase()}) +
+
+ Amount Paid + ${payment.amount_received || payment.amount_crypto} ${coinInfo?.ticker || payment.coin.toUpperCase()} +
+
+ Payment Address + ${payment.address} +
+
+ Total (USD) + $${Number(payment.amount_usd).toFixed(2)} +
+
+ + ${txs.length > 0 ? `
+
Transactions
+ + + ${txRows} +
Transaction IDAmountDate
+
` : ""} + + + +`; + + // Lock the receipt by storing it + await sql`UPDATE payments SET receipt_html = ${receiptHtml} WHERE id = ${payment.id}`; + + set.headers["content-type"] = "text/html; charset=utf-8"; + return receiptHtml; + }) + ; diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index d7d2d4e..720b9a6 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -305,6 +305,30 @@ export const dashboard = new Elysia() return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: params.id, coins, invoice }); }) + // Receipt (proxy to pay service, serves HTML directly) + .get("/dashboard/checkout/:id/receipt", async ({ cookie, headers, params, set }) => { + const resolved = await getAccountId(cookie, headers); + if (!resolved?.accountId) return redirect("/dashboard"); + + const payApi = process.env.PAY_API || "https://pay.pingql.com"; + const key = cookie?.pingql_key?.value; + try { + const res = await fetch(`${payApi}/checkout/${params.id}/receipt`, { + headers: { "Authorization": `Bearer ${key}` }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + set.status = res.status; + return data.error || "Receipt not available"; + } + set.headers["content-type"] = "text/html; charset=utf-8"; + return await res.text(); + } catch { + set.status = 500; + return "Could not load receipt"; + } + }) + // Create checkout via form POST (no-JS) .post("/dashboard/checkout", async ({ cookie, headers, body }) => { const resolved = await getAccountId(cookie, headers); diff --git a/apps/web/src/views/checkout.ejs b/apps/web/src/views/checkout.ejs index 43da6a0..6f54e90 100644 --- a/apps/web/src/views/checkout.ejs +++ b/apps/web/src/views/checkout.ejs @@ -175,7 +175,10 @@

Payment confirmed

Your plan has been activated.

- Back to settings + <% if (inv.address) { %> View on block explorer → <% } %> diff --git a/apps/web/src/views/settings.ejs b/apps/web/src/views/settings.ejs index 4f0f5e6..19dc885 100644 --- a/apps/web/src/views/settings.ejs +++ b/apps/web/src/views/settings.ejs @@ -51,39 +51,6 @@ <% } %> - - <% if (it.invoices && it.invoices.length > 0) { %> -
-

Invoices

-
- <% it.invoices.forEach(function(inv) { - const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' }; - const statusColor = statusColors[inv.status] || 'gray'; - const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - const planLabel = inv.plan === 'lifetime' ? 'Lifetime' : `Pro × ${inv.months}mo`; - %> -
-
- -
- <%= planLabel %> - $<%= Number(inv.amount_usd).toFixed(2) %> · <%= inv.coin.toUpperCase() %> -
-
-
- <%= date %> - <% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %> - View - <% } else if (inv.status === 'paid' && inv.txid) { %> - Paid - <% } %> -
-
- <% }) %> -
-
- <% } %> -

Account

@@ -184,6 +151,40 @@
<% } %> + + <% if (it.invoices && it.invoices.length > 0) { %> +
+

Invoices

+
+ <% it.invoices.forEach(function(inv) { + const statusColors = { paid: 'green', confirming: 'blue', pending: 'yellow', underpaid: 'orange' }; + const statusColor = statusColors[inv.status] || 'gray'; + const date = new Date(inv.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + const planLabel = inv.plan === 'lifetime' ? 'Lifetime' : `Pro × ${inv.months}mo`; + %> +
+
+ +
+ <%= planLabel %> + $<%= Number(inv.amount_usd).toFixed(2) %> · <%= inv.coin.toUpperCase() %> +
+
+
+ <%= date %> + <% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %> + View + <% } else if (inv.status === 'paid') { %> + Receipt + Paid + <% } %> +
+
+ <% }) %> +
+
+ <% } %> +