feat: add receipts to the payment service

This commit is contained in:
2026-03-19 13:40:17 +04:00
parent a7f56c69d3
commit 113c1101c4
6 changed files with 198 additions and 35 deletions
+2
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");
}
+1 -1
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
+133
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;
})
;