130 lines
5.5 KiB
TypeScript
130 lines
5.5 KiB
TypeScript
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;
|
||
}
|