import { createSignal } from "solid-js" import QRCode from "qrcode" import { ensureInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api" import { methodLabel } from "@/lib/paymentMethod" import { formatUsd } from "@/lib/format" import { PLATFORM_NAME } from "@/lib/state" const fmtDate = (seconds: number) => new Date(seconds * 1000).toLocaleDateString() function escapeHtml(value: string) { const map: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" } return value.replace(/[&<>"']/g, (c) => map[c]) } // Generates a printable invoice and opens the browser's print/save-as-PDF dialog. // No PDF dependency: the invoice is rendered as standalone HTML into an off-screen // iframe so the current page is never disturbed. The bitcoin line is included only // for Lightning-relevant invoices (never card-paid or void) to avoid spuriously // minting a bolt11. export function useInvoicePdf() { const [printing, setPrinting] = createSignal(false) // `loadItems` overrides how line items are fetched — used by the draft invoice, // whose sentinel id has no row in the per-invoice items endpoint. async function printInvoice(invoice: Invoice, loadItems?: () => Promise) { if (printing()) return setPrinting(true) try { const fetchItems = loadItems ?? (() => listInvoiceItems(invoice.id)) const items = await fetchItems().catch(e => { console.error("Failed to load invoice line items", e) return [] as InvoiceItem[] }) let sats: number | undefined let qrDataUrl: string | undefined if (invoice.method !== "stripe" && invoice.voided_at == null) { try { const bolt11 = await ensureInvoiceBolt11(invoice.id) sats = Math.round(bolt11.msats / 1000) if (invoice.paid_at == null) { qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 }) } } catch { // no bolt11 available — omit the bitcoin line } } printHtml(buildHtml({ invoice, items, sats, qrDataUrl })) } finally { setPrinting(false) } } return { printInvoice, printing } } function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number; qrDataUrl?: string }): string { const { invoice, items, sats, qrDataUrl } = opts // The draft invoice carries the sentinel id and no lifecycle timestamps, so // invoiceStatus would read it as "open" — label it "draft" explicitly. const status = invoice.id === "draft" ? "draft" : invoiceStatus(invoice) const rows = items.length ? items .map((i) => `${escapeHtml(i.description || "Charge")}${formatUsd(i.amount)}`) .join("") : `Relay subscription${formatUsd(invoice.amount)}` const satsRow = sats != null ? `Bitcoin equivalent${sats.toLocaleString()} sats` : "" const methodLine = invoice.method ? `
Paid via ${escapeHtml(methodLabel(invoice.method))}
` : "" const qr = qrDataUrl ? `
Lightning invoice QR
Scan to pay by Lightning
` : "" return `Invoice ${escapeHtml(invoice.id)}

${escapeHtml(PLATFORM_NAME)}

Invoice
${status}
Invoice ID: ${escapeHtml(invoice.id)}
Billed to: ${escapeHtml(invoice.tenant_pubkey)}
Issued: ${fmtDate(invoice.created_at)}
Period: ${fmtDate(invoice.period_start)} – ${fmtDate(invoice.period_end)}
${methodLine}
${rows}${satsRow}
DescriptionAmount
Total${formatUsd(invoice.amount)}
${qr} ` } function printHtml(html: string) { const iframe = document.createElement("iframe") iframe.style.position = "fixed" iframe.style.right = "0" iframe.style.bottom = "0" iframe.style.width = "0" iframe.style.height = "0" iframe.style.border = "0" document.body.appendChild(iframe) const win = iframe.contentWindow const doc = win?.document if (!win || !doc) { iframe.remove() return } doc.open() doc.write(html) doc.close() const cleanup = () => window.setTimeout(() => iframe.remove(), 1000) win.onafterprint = cleanup // Let the iframe lay out (and decode the QR image) before printing. window.setTimeout(() => { win.focus() win.print() window.setTimeout(cleanup, 60000) }, 150) }