Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions frontend/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
92 changes: 77 additions & 15 deletions frontend/src/app/(public)/pay/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -367,6 +372,7 @@ export default function PaymentPage() {
const [actionError, setActionError] = useState<string | null>(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")) {
Expand Down Expand Up @@ -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 <LoadingSkeleton />;

Expand Down Expand Up @@ -870,22 +919,35 @@ export default function PaymentPage() {

{/* Settled success note */}
{isSettled && (
<div
className="rounded-xl border p-4 text-center"
style={{
borderColor: "var(--checkout-primary-border)",
backgroundColor: "var(--checkout-primary-subtle)",
}}
>
<p
className="text-sm font-semibold"
style={{ color: "var(--checkout-primary)" }}
<div className="flex flex-col gap-3">
<div
className="rounded-xl border p-4 text-center"
style={{
borderColor: "var(--checkout-primary-border)",
backgroundColor: "var(--checkout-primary-subtle)",
}}
>
{t("receivedTitle")}
</p>
<p className="mt-1 text-xs text-slate-400">
{t("receivedDescription")}
</p>
<p
className="text-sm font-semibold"
style={{ color: "var(--checkout-primary)" }}
>
{t("receivedTitle")}
</p>
<p className="mt-1 text-xs text-slate-400">
{t("receivedDescription")}
</p>
</div>

<button
type="button"
onClick={handleDownloadReceipt}
disabled={isDownloadingReceipt}
className="flex h-11 w-full items-center justify-center rounded-xl border border-white/15 bg-white/5 px-4 text-sm font-semibold text-white transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-60"
>
{isDownloadingReceipt
? t("downloadReceiptLoading")
: t("downloadReceipt")}
</button>
</div>
)}

Expand Down
129 changes: 129 additions & 0 deletions frontend/src/lib/receipt-pdf.ts
Original file line number Diff line number Diff line change
@@ -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" });
}
22 changes: 22 additions & 0 deletions frontend/tests/e2e/checkout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading