diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 245bc0a7..438cad98 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -221,6 +221,11 @@ "payWithFallback": "Pay with Wallet", "receivedTitle": "This payment has been received.", "receivedDescription": "The transaction was confirmed on the Stellar network.", + "downloadReceipt": "Download Receipt", + "downloadReceiptLoading": "Preparing Receipt...", + "receiptDownloaded": "Receipt download started.", + "receiptDownloadFailed": "Could not generate the receipt PDF.", + "receiptHashUnavailable": "Unavailable", "failedTitle": "This payment has failed.", "failedDescription": "Contact the merchant if you believe this is an error.", "status": { diff --git a/frontend/messages/es.json b/frontend/messages/es.json index b607a5b3..0da8a367 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -221,6 +221,11 @@ "payWithFallback": "Pagar con billetera", "receivedTitle": "Este pago ya fue recibido.", "receivedDescription": "La transaccion fue confirmada en la red Stellar.", + "downloadReceipt": "Descargar recibo", + "downloadReceiptLoading": "Preparando recibo...", + "receiptDownloaded": "La descarga del recibo ha comenzado.", + "receiptDownloadFailed": "No se pudo generar el PDF del recibo.", + "receiptHashUnavailable": "No disponible", "failedTitle": "Este pago ha fallado.", "failedDescription": "Contacta al comercio si crees que esto es un error.", "status": { diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index ec8cc4ea..75deb61a 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -221,6 +221,11 @@ "payWithFallback": "Pagar com carteira", "receivedTitle": "Este pagamento foi recebido.", "receivedDescription": "A transacao foi confirmada na rede Stellar.", + "downloadReceipt": "Baixar recibo", + "downloadReceiptLoading": "Preparando recibo...", + "receiptDownloaded": "O download do recibo foi iniciado.", + "receiptDownloadFailed": "Nao foi possivel gerar o PDF do recibo.", + "receiptHashUnavailable": "Indisponivel", "failedTitle": "Este pagamento falhou.", "failedDescription": "Entre em contato com o lojista se voce acredita que isto e um erro.", "status": { diff --git a/frontend/src/app/(public)/pay/[id]/page.tsx b/frontend/src/app/(public)/pay/[id]/page.tsx index 632c73c5..8a213ad7 100644 --- a/frontend/src/app/(public)/pay/[id]/page.tsx +++ b/frontend/src/app/(public)/pay/[id]/page.tsx @@ -6,6 +6,7 @@ import { useLocale, useTranslations } from "next-intl"; import { useWallet } from "@/lib/wallet-context"; import { usePayment } from "@/lib/usePayment"; import { useAssetMetadata } from "@/lib/useAssetMetadata"; +import { createReceiptPdf } from "@/lib/receipt-pdf"; import CopyButton from "@/components/CopyButton"; import WalletSelector from "@/components/WalletSelector"; import toast from "react-hot-toast"; @@ -314,6 +315,10 @@ function buildSep7Uri(payment: PaymentDetails) { return `web+stellar:pay?${params.toString()}`; } +function buildReceiptFilename(paymentId: string) { + return `receipt-${paymentId.replace(/[^a-zA-Z0-9_-]/g, "-")}.pdf`; +} + // ─── Skeleton ───────────────────────────────────────────────────────────────── function LoadingSkeleton() { @@ -367,6 +372,7 @@ export default function PaymentPage() { const [actionError, setActionError] = useState(null); const [showRawIntent, setShowRawIntent] = useState(false); const [showConfetti, setShowConfetti] = useState(false); + const [isDownloadingReceipt, setIsDownloadingReceipt] = useState(false); useEffect(() => { if (payment && (payment.status === "confirmed" || payment.status === "completed")) { @@ -543,6 +549,49 @@ export default function PaymentPage() { } }; + const handleDownloadReceipt = async () => { + if (!payment) return; + + try { + setIsDownloadingReceipt(true); + setActionError(null); + + const blob = createReceiptPdf({ + merchantName: branding.merchant_name, + paymentId: payment.id, + amount: payment.amount.toLocaleString(locale, { + minimumFractionDigits: 0, + maximumFractionDigits: 7, + }), + asset: payment.asset.toUpperCase(), + status: t(`status.${payment.status.toLowerCase()}`), + date: new Date(payment.created_at).toLocaleString(locale, { + dateStyle: "medium", + timeStyle: "short", + }), + recipient: payment.recipient, + transactionHash: payment.tx_id ?? t("receiptHashUnavailable"), + description: payment.description, + }); + + const downloadUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = buildReceiptFilename(payment.id); + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000); + toast.success(t("receiptDownloaded")); + } catch { + const msg = t("receiptDownloadFailed"); + setActionError(msg); + toast.error(msg); + } finally { + setIsDownloadingReceipt(false); + } + }; + // ── Early returns ────────────────────────────────────────────────────────── if (loading) return ; @@ -870,22 +919,35 @@ export default function PaymentPage() { {/* Settled success note */} {isSettled && ( -
-

+

- {t("receivedTitle")} -

-

- {t("receivedDescription")} -

+

+ {t("receivedTitle")} +

+

+ {t("receivedDescription")} +

+
+ +
)} diff --git a/frontend/src/lib/receipt-pdf.ts b/frontend/src/lib/receipt-pdf.ts new file mode 100644 index 00000000..85fd0b24 --- /dev/null +++ b/frontend/src/lib/receipt-pdf.ts @@ -0,0 +1,129 @@ +export interface ReceiptPdfData { + merchantName?: string | null; + paymentId: string; + amount: string; + asset: string; + status: string; + date: string; + recipient: string; + transactionHash: string; + description?: string | null; +} + +function normalizePdfText(value: string) { + return value + .normalize("NFKD") + .replace(/[^\x20-\x7E]/g, "") + .replace(/\\/g, "\\\\") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)"); +} + +function wrapLine(value: string, maxLength = 76) { + const words = value.trim().split(/\s+/); + const lines: string[] = []; + let current = ""; + + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length <= maxLength) { + current = candidate; + continue; + } + + if (current) { + lines.push(current); + } + + if (word.length <= maxLength) { + current = word; + continue; + } + + for (let i = 0; i < word.length; i += maxLength) { + lines.push(word.slice(i, i + maxLength)); + } + current = ""; + } + + if (current) { + lines.push(current); + } + + return lines.length ? lines : [""]; +} + +function buildContentLines(data: ReceiptPdfData) { + const heading = data.merchantName?.trim() || "Stellar Payment Receipt"; + const detailLines = [ + `Amount: ${data.amount} ${data.asset}`, + `Status: ${data.status}`, + `Date: ${data.date}`, + `Transaction hash: ${data.transactionHash}`, + `Payment ID: ${data.paymentId}`, + `Recipient: ${data.recipient}`, + ]; + + if (data.description?.trim()) { + detailLines.push(`Description: ${data.description.trim()}`); + } + + return [ + { text: heading, fontSize: 24, leading: 30 }, + { text: "Receipt", fontSize: 15, leading: 24 }, + ...detailLines.flatMap((line) => + wrapLine(line).map((wrapped) => ({ + text: wrapped, + fontSize: 12, + leading: 18, + })), + ), + ]; +} + +export function createReceiptPdf(data: ReceiptPdfData) { + const lines = buildContentLines(data); + const content: string[] = ["BT", "/F1 24 Tf", "50 750 Td"]; + let firstLine = true; + + for (const line of lines) { + if (!firstLine) { + content.push(`0 -${line.leading} Td`); + content.push(`/F1 ${line.fontSize} Tf`); + } + content.push(`(${normalizePdfText(line.text)}) Tj`); + firstLine = false; + } + + content.push("ET"); + + const stream = content.join("\n"); + const objects = [ + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj", + "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj", + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj", + "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj", + `5 0 obj\n<< /Length ${stream.length} >>\nstream\n${stream}\nendstream\nendobj`, + ]; + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = [0]; + + for (const object of objects) { + offsets.push(pdf.length); + pdf += `${object}\n`; + } + + const xrefOffset = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += "0000000000 65535 f \n"; + + for (let i = 1; i < offsets.length; i += 1) { + pdf += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`; + } + + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`; + pdf += `startxref\n${xrefOffset}\n%%EOF`; + + return new Blob([pdf], { type: "application/pdf" }); +} diff --git a/frontend/tests/e2e/checkout.spec.ts b/frontend/tests/e2e/checkout.spec.ts index 91aaccdf..779b2f4b 100644 --- a/frontend/tests/e2e/checkout.spec.ts +++ b/frontend/tests/e2e/checkout.spec.ts @@ -165,6 +165,15 @@ test.describe("Checkout – Rendering", () => { ).toBeVisible(); }); + test("shows receipt download action for settled payments", async ({ page }) => { + await mockPayment(page, { status: "completed" }); + await page.goto(PAY_URL); + + await expect( + page.getByRole("button", { name: "Download Receipt" }) + ).toBeVisible(); + }); + test("shows failed note for a failed payment", async ({ page }) => { await mockPayment(page, { status: "failed" }); await page.goto(PAY_URL); @@ -199,6 +208,19 @@ test.describe("Checkout – Rendering", () => { await expect(page.getByText("Transaction")).not.toBeVisible(); }); + + test("downloads a receipt PDF for a completed payment", async ({ page }) => { + const txHash = + "3b5e2a1f8c9d4e6f7a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f"; + await mockPayment(page, { status: "completed", tx_id: txHash }); + await page.goto(PAY_URL); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Download Receipt" }).click(); + + const download = await downloadPromise; + expect(await download.suggestedFilename()).toBe(`receipt-${PAYMENT_ID}.pdf`); + }); }); test.describe("Checkout – QR Code", () => {