diff --git a/src/components/InvoicePdfDownloadButton.tsx b/src/components/InvoicePdfDownloadButton.tsx
new file mode 100644
index 0000000..933c3a9
--- /dev/null
+++ b/src/components/InvoicePdfDownloadButton.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { QRCodeCanvas } from "qrcode.react";
+import type { Invoice } from "@/utils/soroban";
+import {
+ getInvoicePageUrl,
+ getInvoicePdfFilename,
+ getInvoicePdfRows,
+} from "@/utils/invoicePdf";
+
+interface InvoicePdfDownloadButtonProps {
+ invoice: Invoice;
+}
+
+export default function InvoicePdfDownloadButton({ invoice }: InvoicePdfDownloadButtonProps) {
+ const qrCanvasRef = useRef(null);
+ const [isGenerating, setIsGenerating] = useState(false);
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
+ const invoiceUrl = getInvoicePageUrl(invoice.id, origin);
+
+ const downloadPdf = async () => {
+ setIsGenerating(true);
+ try {
+ const { jsPDF } = await import("jspdf");
+ const doc = new jsPDF({ unit: "pt", format: "letter" });
+ const rows = getInvoicePdfRows(invoice);
+ const qrDataUrl = qrCanvasRef.current?.toDataURL("image/png");
+
+ doc.setFillColor(16, 24, 39);
+ doc.rect(0, 0, 612, 112, "F");
+ doc.setTextColor(255, 255, 255);
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(24);
+ doc.text("ILN Invoice", 48, 52);
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(11);
+ doc.text("Invoice Liquidity Network", 48, 76);
+
+ doc.setTextColor(17, 24, 39);
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(18);
+ doc.text(`Invoice #${invoice.id.toString()}`, 48, 154);
+
+ doc.setFontSize(10);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(100, 116, 139);
+ doc.text("Generated client-side from the ILN invoice detail page.", 48, 174);
+
+ let y = 220;
+ rows.forEach((row) => {
+ doc.setTextColor(100, 116, 139);
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(9);
+ doc.text(row.label.toUpperCase(), 48, y);
+
+ doc.setTextColor(17, 24, 39);
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(11);
+ const wrapped = doc.splitTextToSize(row.value, 360);
+ doc.text(wrapped, 48, y + 18);
+ y += 42 + Math.max(0, wrapped.length - 1) * 14;
+ });
+
+ if (qrDataUrl) {
+ doc.setDrawColor(226, 232, 240);
+ doc.roundedRect(424, 142, 140, 166, 12, 12);
+ doc.addImage(qrDataUrl, "PNG", 444, 160, 100, 100);
+ doc.setTextColor(100, 116, 139);
+ doc.setFontSize(8);
+ doc.text("Scan to open invoice", 446, 282);
+ }
+
+ doc.setTextColor(71, 85, 105);
+ doc.setFontSize(9);
+ doc.text("Invoice page", 48, 720);
+ doc.setTextColor(37, 99, 235);
+ doc.text(invoiceUrl, 48, 736);
+
+ doc.save(getInvoicePdfFilename(invoice.id));
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/__tests__/InvoicePdfDownloadButton.test.tsx b/src/components/__tests__/InvoicePdfDownloadButton.test.tsx
new file mode 100644
index 0000000..bc5e0c9
--- /dev/null
+++ b/src/components/__tests__/InvoicePdfDownloadButton.test.tsx
@@ -0,0 +1,65 @@
+import React from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import InvoicePdfDownloadButton from "../InvoicePdfDownloadButton";
+import type { Invoice } from "@/utils/soroban";
+
+const save = vi.fn();
+const text = vi.fn();
+const addImage = vi.fn();
+
+vi.mock("qrcode.react", async () => {
+ const ReactModule = await vi.importActual("react");
+ const MockQRCodeCanvas = ReactModule.forwardRef((_props, ref) => (
+
+ ));
+ MockQRCodeCanvas.displayName = "MockQRCodeCanvas";
+ return { QRCodeCanvas: MockQRCodeCanvas };
+});
+
+vi.mock("jspdf", () => ({
+ jsPDF: vi.fn(function MockJsPDF(this: Record) {
+ this.setFillColor = vi.fn();
+ this.rect = vi.fn();
+ this.setTextColor = vi.fn();
+ this.setFont = vi.fn();
+ this.setFontSize = vi.fn();
+ this.text = text;
+ this.splitTextToSize = vi.fn((value: string) => [value]);
+ this.setDrawColor = vi.fn();
+ this.roundedRect = vi.fn();
+ this.addImage = addImage;
+ this.save = save;
+ }),
+}));
+
+const invoice: Invoice = {
+ id: 7n,
+ freelancer: "GFREELANCERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ payer: "GPAYERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
+ amount: 1_000_000_000n,
+ due_date: 1_900_000_000n,
+ discount_rate: 300,
+ status: "Pending",
+};
+
+describe("InvoicePdfDownloadButton", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ Object.defineProperty(window.HTMLCanvasElement.prototype, "toDataURL", {
+ configurable: true,
+ value: vi.fn(() => "data:image/png;base64,qr"),
+ });
+ });
+
+ it("generates and saves the required invoice PDF filename", async () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /download pdf/i }));
+
+ await waitFor(() => expect(save).toHaveBeenCalledWith("ILN-Invoice-7.pdf"));
+ expect(addImage).toHaveBeenCalledWith("data:image/png;base64,qr", "PNG", 444, 160, 100, 100);
+ expect(text).toHaveBeenCalledWith("ILN Invoice", 48, 52);
+ expect(text).toHaveBeenCalledWith("Invoice page", 48, 720);
+ });
+});
diff --git a/src/utils/federation.ts b/src/utils/federation.ts
index 11922eb..391a8eb 100644
--- a/src/utils/federation.ts
+++ b/src/utils/federation.ts
@@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants";
const horizonServer = new rpc.Server(RPC_URL);
const federationCache = new Map();
+interface AccountHomeDomain {
+ home_domain?: string;
+ homeDomain?: string;
+}
+
export async function resolveFederatedAddress(address: string): Promise {
if (!address) return address;
const cached = federationCache.get(address);
@@ -11,10 +16,11 @@ export async function resolveFederatedAddress(address: string): Promise
try {
const account = await horizonServer.getAccount(address);
- const homeDomain = account.home_domain ?? (account as any).homeDomain;
- if (!homeDomain) return address;
+ const { home_domain: homeDomain, homeDomain: camelHomeDomain } = account as AccountHomeDomain;
+ const resolvedHomeDomain = homeDomain ?? camelHomeDomain;
+ if (!resolvedHomeDomain) return address;
- const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`);
+ const stellarTomlResponse = await fetch(`https://${resolvedHomeDomain}/.well-known/stellar.toml`);
if (!stellarTomlResponse.ok) return address;
const toml = await stellarTomlResponse.text();