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 sql from "./db";
import { getAddressInfo, getAddressInfoBulk } from "./freedom"; import { getAddressInfo, getAddressInfoBulk } from "./freedom";
import { COINS } from "./plans"; import { COINS } from "./plans";
import { generateReceipt } from "./receipt";
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st"; const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st";
const THRESHOLD = 0.95; const THRESHOLD = 0.95;
@ -83,6 +84,7 @@ async function evaluatePayment(paymentId: number) {
if (newStatus === "paid") { 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 sql`UPDATE payments SET status = 'paid', amount_received = ${received.toFixed(8)}, paid_at = now() WHERE id = ${paymentId} AND status != 'paid'`;
await applyPlan(payment); await applyPlan(payment);
await generateReceipt(paymentId).catch(e => console.error(`Receipt generation failed for ${paymentId}:`, e));
addressMap.delete(payment.address); addressMap.delete(payment.address);
console.log(`Payment ${paymentId} paid`); console.log(`Payment ${paymentId} paid`);
} else { } 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 { derive } from "./address";
import { getExchangeRates, getAvailableCoins, fetchQrBase64 } from "./freedom"; import { getExchangeRates, getAvailableCoins, fetchQrBase64 } from "./freedom";
import { PLANS, COINS } from "./plans"; import { PLANS, COINS } from "./plans";
import { generateReceipt } from "./receipt";
import { watchPayment } from "./monitor"; import { watchPayment } from "./monitor";
// Resolve account from key (same logic as API/web apps) // 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 }) => { .get("/checkout/:id/receipt", async ({ accountId, params, set }) => {
const [payment] = await sql` const [payment] = await sql`
SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId} 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) { 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 (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"; set.headers["content-type"] = "text/html; charset=utf-8";
return payment.receipt_html; return payment.receipt_html || await generateReceipt(payment.id);
}
// 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

@ -423,10 +423,6 @@
</div> </div>
</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>
</div> </div>
</section> </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. 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> </p>
<h2>Where data is stored</h2>
<p>
Servers are in Prague, Czech Republic (EU). GDPR applies.
</p>
<h2>Security</h2> <h2>Security</h2>
<p> <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. 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.