diff --git a/app/i/[id]/__tests__/InvoiceDetailPage.test.tsx b/app/i/[id]/__tests__/InvoiceDetailPage.test.tsx
new file mode 100644
index 0000000..04b926b
--- /dev/null
+++ b/app/i/[id]/__tests__/InvoiceDetailPage.test.tsx
@@ -0,0 +1,114 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import InvoiceDetailPage from "../page";
+import { getInvoice, type Invoice } from "@/utils/soroban";
+
+vi.mock("@/components/ActivityFeed", () => ({
+ default: () =>
Activity feed
,
+}));
+
+vi.mock("@/components/ChangeInvoiceTokenModal", () => ({
+ default: ({ onSuccess }: { onSuccess: (token: string) => void }) => (
+
+
+
+ ),
+}));
+
+const connect = vi.fn();
+let walletState = {
+ address: "",
+ connect,
+};
+
+vi.mock("@/context/WalletContext", () => ({
+ useWallet: () => walletState,
+}));
+
+vi.mock("@/hooks/useApprovedTokens", () => ({
+ useApprovedTokens: () => ({
+ tokens: [
+ { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" },
+ { contractId: "CEURC", name: "Euro Coin", symbol: "EURC", decimals: 7, iconLabel: "EU" },
+ ],
+ tokenMap: new Map([
+ ["CUSDC", { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" }],
+ ["CEURC", { contractId: "CEURC", name: "Euro Coin", symbol: "EURC", decimals: 7, iconLabel: "EU" }],
+ ]),
+ defaultToken: { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" },
+ }),
+}));
+
+vi.mock("@/utils/soroban", async () => {
+ const actual = await vi.importActual("@/utils/soroban");
+ return {
+ ...actual,
+ getInvoice: vi.fn(),
+ };
+});
+
+const invoice: Invoice = {
+ id: 9n,
+ status: "Pending",
+ freelancer: "GFREELANCER",
+ payer: "GPAYER",
+ amount: 10_000_000n,
+ due_date: 1_900_000_000n,
+ discount_rate: 300,
+ token: "CUSDC",
+};
+
+function params(id = "9") {
+ const value = Promise.resolve({ id }) as Promise<{ id: string }> & { _resolvedValue?: { id: string } };
+ value._resolvedValue = { id };
+ return value;
+}
+
+describe("InvoiceDetailPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ walletState = {
+ address: "GFREELANCER",
+ connect,
+ };
+ vi.mocked(getInvoice).mockResolvedValue(invoice);
+ });
+
+ it("shows Change Token only for the submitting freelancer while pending", async () => {
+ render();
+
+ expect(await screen.findByRole("heading", { name: "Invoice #9" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Change Token" })).toBeInTheDocument();
+ });
+
+ it("does not show Change Token for non-submitters", async () => {
+ walletState = {
+ address: "GOTHER",
+ connect,
+ };
+
+ render();
+
+ expect(await screen.findByRole("heading", { name: "Invoice #9" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Change Token" })).not.toBeInTheDocument();
+ expect(screen.getByText("Only the submitting freelancer can change the invoice token before funding.")).toBeInTheDocument();
+ });
+
+ it("does not show Change Token after the invoice leaves Pending", async () => {
+ vi.mocked(getInvoice).mockResolvedValue({ ...invoice, status: "Funded" });
+
+ render();
+
+ expect(await screen.findByRole("heading", { name: "Invoice #9" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Change Token" })).not.toBeInTheDocument();
+ });
+
+ it("updates the visible token immediately after a successful modal change", async () => {
+ render();
+
+ fireEvent.click(await screen.findByRole("button", { name: "Change Token" }));
+ fireEvent.click(screen.getByText("Mock change token success"));
+
+ await waitFor(() => expect(screen.getByText("EURC")).toBeInTheDocument());
+ });
+});
diff --git a/src/components/__tests__/ChangeInvoiceTokenModal.test.tsx b/src/components/__tests__/ChangeInvoiceTokenModal.test.tsx
new file mode 100644
index 0000000..d66c3a3
--- /dev/null
+++ b/src/components/__tests__/ChangeInvoiceTokenModal.test.tsx
@@ -0,0 +1,111 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import ChangeInvoiceTokenModal from "@/components/ChangeInvoiceTokenModal";
+import { buildConvertInvoiceTokenTransaction, type Invoice } from "@/utils/soroban";
+
+const runTransaction = vi.fn();
+const addToast = vi.fn(() => "toast-id");
+const updateToast = vi.fn();
+
+vi.mock("@/hooks/useTransaction", () => ({
+ useTransaction: () => ({
+ isPending: false,
+ runTransaction,
+ }),
+}));
+
+vi.mock("@/context/ToastContext", () => ({
+ useToast: () => ({ addToast, updateToast }),
+}));
+
+vi.mock("@/hooks/useApprovedTokens", () => ({
+ useApprovedTokens: () => ({
+ tokens: [
+ { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" },
+ { contractId: "CEURC", name: "Euro Coin", symbol: "EURC", decimals: 7, iconLabel: "EU" },
+ ],
+ tokenMap: new Map([
+ ["CUSDC", { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" }],
+ ["CEURC", { contractId: "CEURC", name: "Euro Coin", symbol: "EURC", decimals: 7, iconLabel: "EU" }],
+ ]),
+ defaultToken: { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" },
+ isLoading: false,
+ error: null,
+ }),
+}));
+
+vi.mock("@/utils/soroban", async () => {
+ const actual = await vi.importActual("@/utils/soroban");
+ return {
+ ...actual,
+ buildConvertInvoiceTokenTransaction: vi.fn().mockResolvedValue("tx"),
+ };
+});
+
+const invoice: Invoice = {
+ id: 12n,
+ status: "Pending",
+ freelancer: "GFREELANCER",
+ payer: "GPAYER",
+ amount: 10_000_000n,
+ due_date: 1_900_000_000n,
+ discount_rate: 300,
+ token: "CUSDC",
+};
+
+describe("ChangeInvoiceTokenModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ runTransaction.mockImplementation(async (builder: () => Promise) => {
+ await builder();
+ return { txHash: "hash123" };
+ });
+ });
+
+ it("shows the current token, allowlisted token selector, and denomination warning", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("heading", { name: "Change Token" })).toBeInTheDocument();
+ expect(screen.getByText("This changes the currency your invoice is denominated in.")).toBeInTheDocument();
+ expect(screen.getByText("USDC")).toBeInTheDocument();
+ expect(screen.getByRole("combobox", { name: /new token/i })).toHaveValue("CEURC");
+ });
+
+ it("builds and submits convert_invoice_token through useTransaction", async () => {
+ const onClose = vi.fn();
+ const onSuccess = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Change Token" }));
+
+ await waitFor(() =>
+ expect(buildConvertInvoiceTokenTransaction).toHaveBeenCalledWith({
+ submitter: "GFREELANCER",
+ invoiceId: 12n,
+ newToken: "CEURC",
+ })
+ );
+ expect(runTransaction).toHaveBeenCalledTimes(1);
+ expect(updateToast).toHaveBeenCalledWith(
+ "toast-id",
+ expect.objectContaining({ type: "success", txHash: "hash123" })
+ );
+ expect(onSuccess).toHaveBeenCalledWith("CEURC");
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts
new file mode 100644
index 0000000..aae4305
--- /dev/null
+++ b/src/hooks/useTransaction.ts
@@ -0,0 +1,45 @@
+"use client";
+
+import { useState } from "react";
+import { useWallet } from "@/context/WalletContext";
+import { submitSignedTransaction } from "@/utils/soroban";
+import type { Transaction } from "@stellar/stellar-sdk";
+
+export type TransactionState = "idle" | "preparing" | "signing" | "success" | "error";
+
+export function useTransaction() {
+ const { signTx } = useWallet();
+ const [state, setState] = useState("idle");
+ const [error, setError] = useState(null);
+
+ const runTransaction = async (buildTransaction: () => Promise) => {
+ setState("preparing");
+ setError(null);
+
+ try {
+ const tx = await buildTransaction();
+ setState("signing");
+ const result = await submitSignedTransaction({ tx, signTx });
+ setState("success");
+ return result;
+ } catch (transactionError) {
+ const message = transactionError instanceof Error ? transactionError.message : "Transaction failed.";
+ setError(message);
+ setState("error");
+ throw transactionError;
+ }
+ };
+
+ const reset = () => {
+ setState("idle");
+ setError(null);
+ };
+
+ return {
+ state,
+ error,
+ isPending: state === "preparing" || state === "signing",
+ runTransaction,
+ reset,
+ };
+}
diff --git a/src/utils/soroban.ts b/src/utils/soroban.ts
index 02aa7a8..3bcd3f3 100644
--- a/src/utils/soroban.ts
+++ b/src/utils/soroban.ts
@@ -1016,6 +1016,44 @@ export async function buildApproveUsdcTransaction(args: {
return buildApproveTokenTransaction(args);
}
+// ─── Write: invoice token conversion ─────────────────────────────────────────
+
+export async function buildConvertInvoiceTokenTransaction({
+ submitter,
+ invoiceId,
+ newToken,
+}: {
+ submitter: string;
+ invoiceId: bigint;
+ newToken: string;
+}) {
+ const account = await server.getAccount(submitter);
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: NETWORK_PASSPHRASE,
+ })
+ .addOperation(
+ Operation.invokeContractFunction({
+ contract: CONTRACT_ID,
+ function: "convert_invoice_token",
+ args: [
+ nativeToScVal(invoiceId, { type: "u64" }),
+ Address.fromString(newToken).toScVal(),
+ ],
+ })
+ )
+ .setTimeout(60)
+ .build();
+
+ const simulated = await server.simulateTransaction(tx);
+ if (!rpc.Api.isSimulationSuccess(simulated)) {
+ const message =
+ "error" in simulated ? simulated.error : "Unable to simulate invoice token conversion.";
+ throw new Error(`Simulation failed: ${message}`);
+ }
+ return rpc.assembleTransaction(tx, simulated).build();
+}
+
// ─── Write: generic signed transaction dispatcher ─────────────────────────────
export async function submitSignedTransaction({