feat: auto-generate receipts
This commit is contained in:
parent
113c1101c4
commit
9881d4f681
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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";
|
|
||||||
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";
|
set.headers["content-type"] = "text/html; charset=utf-8";
|
||||||
return receiptHtml;
|
return payment.receipt_html || await generateReceipt(payment.id);
|
||||||
})
|
})
|
||||||
|
|
||||||
;
|
;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue