feat: add receipts to the payment service

This commit is contained in:
nate 2026-03-19 13:40:17 +04:00
parent a7f56c69d3
commit 113c1101c4
6 changed files with 198 additions and 35 deletions

View File

@ -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");
}

View File

@ -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<string, any>(); // address → payment

View File

@ -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 `<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;font-family:monospace;font-size:12px;word-break:break-all">${tx.txid}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right">${tx.amount} ${coinInfo?.ticker || payment.coin.toUpperCase()}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;text-align:right">${date}</td>
</tr>`;
}).join("");
const receiptHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PingQL Receipt #${payment.id}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; max-width: 640px; margin: 0 auto; padding: 40px 24px; }
.print-btn { background: #2563eb; color: white; border: none; padding: 10px 24px; border-radius: 6px; font-size: 14px; cursor: pointer; margin-bottom: 32px; display: inline-block; }
.print-btn:hover { background: #3b82f6; }
@media print { .no-print { display: none !important; } body { padding: 0; } }
.header { border-bottom: 2px solid #1a1a1a; padding-bottom: 16px; margin-bottom: 24px; }
.logo { font-size: 24px; font-weight: 700; }
.logo span { color: #2563eb; }
.meta { color: #6b7280; font-size: 13px; margin-top: 4px; }
.section { margin-bottom: 24px; }
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; margin-bottom: 8px; }
.row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
.row .label { color: #6b7280; }
.row .value { font-weight: 500; }
.total { border-top: 2px solid #1a1a1a; padding-top: 12px; margin-top: 12px; font-size: 16px; font-weight: 600; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th { text-align: left; padding: 8px 12px; border-bottom: 2px solid #e5e7eb; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
.address { font-family: monospace; font-size: 12px; color: #6b7280; word-break: break-all; }
.footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }
</style>
</head>
<body>
<div class="no-print">
<button class="print-btn" onclick="window.print()">Print Receipt</button>
</div>
<div class="header">
<div class="logo">Ping<span>QL</span></div>
<div class="meta">Receipt #${payment.id} · Issued ${paidDate}</div>
</div>
<div class="section">
<div class="section-title">Plan</div>
<div class="row">
<span class="label">Product</span>
<span class="value">PingQL ${planLabel}</span>
</div>
<div class="row">
<span class="label">Invoice Date</span>
<span class="value">${createdDate}</span>
</div>
<div class="row">
<span class="label">Payment Date</span>
<span class="value">${paidDate}</span>
</div>
</div>
<div class="section">
<div class="section-title">Payment</div>
<div class="row">
<span class="label">Currency</span>
<span class="value">${coinInfo?.label || payment.coin} (${coinInfo?.ticker || payment.coin.toUpperCase()})</span>
</div>
<div class="row">
<span class="label">Amount Paid</span>
<span class="value">${payment.amount_received || payment.amount_crypto} ${coinInfo?.ticker || payment.coin.toUpperCase()}</span>
</div>
<div class="row">
<span class="label">Payment Address</span>
<span class="value address">${payment.address}</span>
</div>
<div class="row total">
<span>Total (USD)</span>
<span>$${Number(payment.amount_usd).toFixed(2)}</span>
</div>
</div>
${txs.length > 0 ? `<div class="section">
<div class="section-title">Transactions</div>
<table>
<thead><tr><th>Transaction ID</th><th style="text-align:right">Amount</th><th style="text-align:right">Date</th></tr></thead>
<tbody>${txRows}</tbody>
</table>
</div>` : ""}
<div class="footer">
PingQL · pingql.com<br>
This receipt was generated on ${paidDate} and cannot be modified.
</div>
</body>
</html>`;
// 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;
})
;

View File

@ -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);

View File

@ -175,7 +175,10 @@
</div>
<p class="text-lg font-semibold text-white">Payment confirmed</p>
<p class="text-sm text-gray-400">Your plan has been activated.</p>
<a href="/dashboard/settings" class="btn-primary inline-block px-5 py-2 text-sm mt-2">Back to settings</a>
<div class="flex items-center justify-center gap-3 mt-2">
<a href="/dashboard/settings" class="btn-primary inline-block px-5 py-2 text-sm">Back to settings</a>
<a href="/dashboard/checkout/<%= inv.id %>/receipt" target="_blank" class="btn-secondary inline-block px-5 py-2 text-sm">View Receipt</a>
</div>
<% if (inv.address) { %>
<a href="https://transaction.st/address/<%= inv.address %>" target="_blank" rel="noopener" class="block text-xs text-gray-500 hover:text-gray-400 transition-colors mt-3">View on block explorer &rarr;</a>
<% } %>

View File

@ -51,39 +51,6 @@
<% } %>
</section>
<!-- Invoices -->
<% if (it.invoices && it.invoices.length > 0) { %>
<section class="card-static p-6">
<h2 class="text-sm font-semibold text-gray-300 mb-4">Invoices</h2>
<div class="space-y-2">
<% 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`;
%>
<div class="flex items-center justify-between p-3 rounded-lg bg-surface border border-border-subtle hover:border-border-strong transition-colors">
<div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-<%= statusColor %>-500 <%= inv.status !== 'paid' ? 'animate-pulse' : '' %>"></span>
<div>
<span class="text-sm text-gray-200"><%= planLabel %></span>
<span class="text-xs text-gray-600 ml-2">$<%= Number(inv.amount_usd).toFixed(2) %> · <%= inv.coin.toUpperCase() %></span>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-500"><%= date %></span>
<% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %>
<a href="/dashboard/checkout/<%= inv.id %>" class="text-xs text-blue-400 hover:text-blue-300">View</a>
<% } else if (inv.status === 'paid' && inv.txid) { %>
<span class="text-xs text-green-500/70">Paid</span>
<% } %>
</div>
</div>
<% }) %>
</div>
</section>
<% } %>
<!-- Account info -->
<section class="card-static p-6">
<h2 class="text-sm font-semibold text-gray-300 mb-4">Account</h2>
@ -184,6 +151,40 @@
</section>
<% } %>
<!-- Invoices -->
<% if (it.invoices && it.invoices.length > 0) { %>
<section class="card-static p-6">
<h2 class="text-sm font-semibold text-gray-300 mb-4">Invoices</h2>
<div class="space-y-2">
<% 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`;
%>
<div class="flex items-center justify-between p-3 rounded-lg bg-surface border border-border-subtle hover:border-border-strong transition-colors">
<div class="flex items-center gap-3">
<span class="w-2 h-2 rounded-full bg-<%= statusColor %>-500 <%= inv.status !== 'paid' ? 'animate-pulse' : '' %>"></span>
<div>
<span class="text-sm text-gray-200"><%= planLabel %></span>
<span class="text-xs text-gray-600 ml-2">$<%= Number(inv.amount_usd).toFixed(2) %> · <%= inv.coin.toUpperCase() %></span>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-500"><%= date %></span>
<% if (inv.status === 'pending' || inv.status === 'underpaid' || inv.status === 'confirming') { %>
<a href="/dashboard/checkout/<%= inv.id %>" class="text-xs text-blue-400 hover:text-blue-300">View</a>
<% } else if (inv.status === 'paid') { %>
<a href="/dashboard/checkout/<%= inv.id %>/receipt" target="_blank" class="text-xs text-gray-500 hover:text-gray-400 transition-colors">Receipt</a>
<span class="text-xs text-green-500/70">Paid</span>
<% } %>
</div>
</div>
<% }) %>
</div>
</section>
<% } %>
</main>