feat: auto-generate receipts

This commit is contained in:
nate 2026-03-19 13:56:14 +04:00
parent 113c1101c4
commit 9881d4f681
5 changed files with 134 additions and 132 deletions

View File

@ -3,6 +3,7 @@
import sql from "./db";
import { getAddressInfo, getAddressInfoBulk } from "./freedom";
import { COINS } from "./plans";
import { generateReceipt } from "./receipt";
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st";
const THRESHOLD = 0.95;
@ -83,6 +84,7 @@ async function evaluatePayment(paymentId: number) {
if (newStatus === "paid") {
await sql`UPDATE payments SET status = 'paid', amount_received = ${received.toFixed(8)}, paid_at = now() WHERE id = ${paymentId} AND status != 'paid'`;
await applyPlan(payment);
await generateReceipt(paymentId).catch(e => console.error(`Receipt generation failed for ${paymentId}:`, e));
addressMap.delete(payment.address);
console.log(`Payment ${paymentId} paid`);
} else {

129
apps/pay/src/receipt.ts Normal file
View File

@ -0,0 +1,129 @@
import sql from "./db";
import { COINS } from "./plans";
export async function generateReceipt(paymentId: number): Promise<string> {
const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`;
if (!payment) throw new Error("Payment not found");
// Already locked — return as-is
if (payment.receipt_html) return payment.receipt_html;
const coinInfo = COINS[payment.coin];
const txs = await sql`
SELECT txid, amount, confirmed, detected_at
FROM payment_txs WHERE payment_id = ${paymentId}
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 html = `<!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 it
await sql`UPDATE payments SET receipt_html = ${html} WHERE id = ${paymentId}`;
return html;
}

View File

@ -3,6 +3,7 @@ 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)
@ -178,7 +179,7 @@ export const routes = new Elysia()
};
})
// Generate or serve a locked receipt for a paid invoice
// 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}
@ -186,129 +187,8 @@ export const routes = new Elysia()
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;
return payment.receipt_html || await generateReceipt(payment.id);
})
;

View File

@ -423,10 +423,6 @@
</div>
</div>
<div class="mt-8 pt-6 border-t border-border-subtle flex items-center gap-2 text-xs text-gray-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"/></svg>
Hosted in EU (Prague) — GDPR compliant
</div>
</div>
</div>
</section>

View File

@ -118,11 +118,6 @@
Ping history is retained for 90 days by default. Account data is kept until you delete your account. If you want everything deleted, contact us and we'll do it.
</p>
<h2>Where data is stored</h2>
<p>
Servers are in Prague, Czech Republic (EU). GDPR applies.
</p>
<h2>Security</h2>
<p>
All data in transit is encrypted via TLS. All data at rest is on encrypted disks. Your account key is the only credential — there's no password database to breach, no hashed passwords to crack, no OAuth tokens to steal.