From f9f2b123fa18e43c98ed01a77c329b11333a885e Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 10:54:24 -0700 Subject: [PATCH] feat: build invoice dispute UI --- app/governance/page.tsx | 10 +- app/pay/[id]/__tests__/PayInvoice.test.tsx | 168 ++++++++++++------ app/pay/[id]/page.tsx | 87 +++++++-- src/components/DisputeInvoiceModal.tsx | 127 +++++++++++++ .../__tests__/DisputeInvoiceModal.test.tsx | 72 ++++++++ src/hooks/useTransaction.ts | 61 +++++++ src/utils/__tests__/disputeEvidence.test.ts | 10 ++ src/utils/disputeEvidence.ts | 9 + src/utils/federation.ts | 9 +- src/utils/soroban.ts | 66 +++++-- 10 files changed, 533 insertions(+), 86 deletions(-) create mode 100644 src/components/DisputeInvoiceModal.tsx create mode 100644 src/components/__tests__/DisputeInvoiceModal.test.tsx create mode 100644 src/hooks/useTransaction.ts create mode 100644 src/utils/__tests__/disputeEvidence.test.ts create mode 100644 src/utils/disputeEvidence.ts diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 87a40dd..4515726 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -23,6 +23,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) { Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" }, Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" }, Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" }, + Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( @@ -172,10 +173,13 @@ export default function GovernancePage() { }, []); useEffect(() => { - load(); + const timeout = window.setTimeout(load, 0); // Refresh every 30 s for real-time vote counts - const interval = setInterval(load, 30_000); - return () => clearInterval(interval); + const interval = window.setInterval(load, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; }, [load]); const filtered = diff --git a/app/pay/[id]/__tests__/PayInvoice.test.tsx b/app/pay/[id]/__tests__/PayInvoice.test.tsx index f4c9ee7..7189a8b 100644 --- a/app/pay/[id]/__tests__/PayInvoice.test.tsx +++ b/app/pay/[id]/__tests__/PayInvoice.test.tsx @@ -1,122 +1,176 @@ -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import PayInvoicePage from '../page'; -import * as soroban from '../../../../utils/soroban'; -import { useWallet } from '../../../../context/WalletContext'; -import { useToast } from '../../../../context/ToastContext'; +import { render, screen, waitFor, fireEvent, within } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import PayInvoicePage from "../page"; +import * as soroban from "@/utils/soroban"; +import { useWallet } from "@/context/WalletContext"; +import { useToast } from "@/context/ToastContext"; +import type { Invoice } from "@/utils/soroban"; // Mock context and utils -vi.mock('../../../../context/WalletContext', () => ({ +vi.mock("@/context/WalletContext", () => ({ useWallet: vi.fn(), })); -vi.mock('../../../../context/ToastContext', () => ({ +vi.mock("@/context/ToastContext", () => ({ useToast: vi.fn(), })); -vi.mock('../../../../utils/soroban', () => ({ +vi.mock("@/utils/soroban", () => ({ getInvoice: vi.fn(), markPaid: vi.fn(), + disputeInvoice: vi.fn(), submitSignedTransaction: vi.fn(), })); -describe('PayInvoicePage', () => { - const mockInvoice = { +const PAYER = "GCSXPYZSTPKX2GDVW6XSJDBE3PVSNSXCCLTGSPJXNF57IJU5EDU6IUDV"; +const OTHER_WALLET = "GDIEC472DEK3S5UWVKYDBXG74R53KMHGXGFIURLJUF6P6JJ352HLLJED"; + +type TestParams = Promise<{ id: string }> & { _resolvedValue?: { id: string } }; + +function createParams(): TestParams { + const params = Promise.resolve({ id: "1" }) as TestParams; + params._resolvedValue = { id: "1" }; + return params; +} + +describe("PayInvoicePage", () => { + const mockInvoice: Invoice = { id: 1n, - freelancer: 'GFREELANCER', - payer: 'GPAYER', + freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + payer: PAYER, amount: 1000000000n, due_date: 1713960000n, - status: 'Funded', + discount_rate: 300, + status: "Funded", }; const mockToast = { - addToast: vi.fn().mockReturnValue('toast-id'), + addToast: vi.fn().mockReturnValue("toast-id"), updateToast: vi.fn(), }; beforeEach(() => { vi.clearAllMocks(); - (useToast as any).mockReturnValue(mockToast); - (soroban.getInvoice as any).mockResolvedValue(mockInvoice); + vi.mocked(useToast).mockReturnValue(mockToast as ReturnType); + vi.mocked(soroban.getInvoice).mockResolvedValue(mockInvoice); }); - it('should render invoice summary without wallet connection', async () => { - (useWallet as any).mockReturnValue({ + it("should render invoice summary without wallet connection", async () => { + vi.mocked(useWallet).mockReturnValue({ address: null, connect: vi.fn(), - }); + } as unknown as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { expect(screen.getByText(/100\s+USDC/)).toBeInTheDocument(); - expect(screen.getByText('Connect Wallet and Pay')).toBeInTheDocument(); + expect(screen.getByText("Connect Wallet and Pay")).toBeInTheDocument(); }); }); - it('should show warning if connected wallet is not the payer', async () => { - (useWallet as any).mockReturnValue({ - address: 'GWRONGWALLET', + it("should show warning if connected wallet is not the payer", async () => { + vi.mocked(useWallet).mockReturnValue({ + address: OTHER_WALLET, connect: vi.fn(), - }); + } as unknown as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { - expect(screen.getByText('Address Mismatch')).toBeInTheDocument(); - expect(screen.getByText('Restricted to Registered Payer')).toBeInTheDocument(); + expect(screen.getByText("Address Mismatch")).toBeInTheDocument(); + expect(screen.getByText("Restricted to Registered Payer")).toBeInTheDocument(); }); }); - it('should show confirmation if invoice is already paid', async () => { - (soroban.getInvoice as any).mockResolvedValue({ + it("should show confirmation if invoice is already paid", async () => { + vi.mocked(soroban.getInvoice).mockResolvedValue({ ...mockInvoice, - status: 'Paid', + status: "Paid", }); - (useWallet as any).mockReturnValue({ - address: 'GPAYER', - }); + vi.mocked(useWallet).mockReturnValue({ + address: PAYER, + } as unknown as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { - expect(screen.getByText('Invoice settled')).toBeInTheDocument(); - expect(screen.getByText('Settlement Complete')).toBeInTheDocument(); + expect(screen.getByText("Invoice settled")).toBeInTheDocument(); + expect(screen.getByText("Settlement Complete")).toBeInTheDocument(); }); }); - it('should call markPaid when Settle button is clicked', async () => { + it("should call markPaid when Settle button is clicked", async () => { const mockSignTx = vi.fn(); - (useWallet as any).mockReturnValue({ - address: 'GPAYER', + vi.mocked(useWallet).mockReturnValue({ + address: PAYER, signTx: mockSignTx, - }); + } as unknown as ReturnType); - (soroban.markPaid as any).mockResolvedValue('mock-tx'); - (soroban.submitSignedTransaction as any).mockResolvedValue({ txHash: 'hash123' }); + vi.mocked(soroban.markPaid).mockResolvedValue( + "mock-tx" as unknown as Awaited>, + ); + vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: "hash123" }); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; - render(); + render(); await waitFor(() => { - expect(screen.getByText('Settle Invoice Now')).toBeInTheDocument(); + expect(screen.getByText("Settle Invoice Now")).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('Settle Invoice Now')); + fireEvent.click(screen.getByText("Settle Invoice Now")); await waitFor(() => { - expect(soroban.markPaid).toHaveBeenCalledWith('GPAYER', 1n); + expect(soroban.markPaid).toHaveBeenCalledWith(PAYER, 1n); expect(soroban.submitSignedTransaction).toHaveBeenCalled(); - expect(mockToast.updateToast).toHaveBeenCalledWith('toast-id', expect.objectContaining({ type: 'success' })); + expect(mockToast.updateToast).toHaveBeenCalledWith("toast-id", expect.objectContaining({ type: "success" })); + }); + }); + + it("shows payer-only dispute action for funded invoices and submits reason hash", async () => { + const mockSignTx = vi.fn(); + vi.mocked(useWallet).mockReturnValue({ + address: PAYER, + signTx: mockSignTx, + } as unknown as ReturnType); + vi.mocked(soroban.disputeInvoice).mockResolvedValue( + "dispute-tx" as unknown as Awaited>, + ); + vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: "dispute-hash" }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Raise Dispute" })); + fireEvent.change(screen.getByLabelText("Evidence description"), { + target: { value: "hello" }, + }); + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Raise Dispute" })); + + await waitFor(() => { + expect(soroban.disputeInvoice).toHaveBeenCalledWith( + PAYER, + 1n, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ); + expect(soroban.submitSignedTransaction).toHaveBeenCalledWith({ + tx: "dispute-tx", + signTx: mockSignTx, + }); + }); + }); + + it("does not show dispute action to non-payers", async () => { + vi.mocked(useWallet).mockReturnValue({ + address: OTHER_WALLET, + connect: vi.fn(), + } as unknown as ReturnType); + + render(); + + await waitFor(() => { + expect(screen.getByText("Restricted to Registered Payer")).toBeInTheDocument(); }); + expect(screen.queryByRole("button", { name: "Raise Dispute" })).not.toBeInTheDocument(); }); }); diff --git a/app/pay/[id]/page.tsx b/app/pay/[id]/page.tsx index 49d1bf2..59efb7a 100644 --- a/app/pay/[id]/page.tsx +++ b/app/pay/[id]/page.tsx @@ -1,12 +1,20 @@ "use client"; import { use, useEffect, useState, useCallback } from "react"; -import { getInvoice, markPaid, submitSignedTransaction, type Invoice } from "@/utils/soroban"; +import { + disputeInvoice, + getInvoice, + markPaid, + submitSignedTransaction, + type Invoice, +} from "@/utils/soroban"; import { formatUsdcFromStroops } from "@/utils/invoiceSubmission"; import { useWallet } from "@/context/WalletContext"; import { useToast } from "@/context/ToastContext"; -import { TESTNET_USDC_TOKEN_ID, NETWORK_NAME } from "@/constants"; +import { NETWORK_NAME } from "@/constants"; import ActivityFeed from "@/components/ActivityFeed"; +import DisputeInvoiceModal from "@/components/DisputeInvoiceModal"; +import { useTransaction } from "@/hooks/useTransaction"; type LoadState = "loading" | "success" | "error"; @@ -18,6 +26,15 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin const [loadState, setLoadState] = useState("loading"); const [error, setError] = useState(null); const [isPaying, setIsPaying] = useState(false); + const [isDisputeModalOpen, setIsDisputeModalOpen] = useState(false); + const [disputeError, setDisputeError] = useState(null); + const disputeTransaction = useTransaction({ + pendingTitle: "Raising dispute...", + pendingMessage: "Please sign the dispute transaction in Freighter.", + successTitle: "Dispute raised", + successMessage: "The invoice status has been updated to disputed.", + errorTitle: "Dispute failed", + }); const invoiceId = BigInt(id); @@ -35,7 +52,8 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin }, [invoiceId]); useEffect(() => { - fetchInvoice(); + const timeout = window.setTimeout(fetchInvoice, 0); + return () => window.clearTimeout(timeout); }, [fetchInvoice]); const handlePay = async () => { @@ -58,19 +76,35 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin }); // Refresh invoice state - fetchInvoice(); - } catch (err: any) { + void fetchInvoice(); + } catch (err) { console.error(err); updateToast(toastId, { type: "error", title: "Payment Failed", - message: err.message || "An unexpected error occurred during payment." + message: err instanceof Error ? err.message : "An unexpected error occurred during payment." }); } finally { setIsPaying(false); } }; + const handleDispute = async (reasonHash: string) => { + if (!address || !invoice) return; + + setDisputeError(null); + try { + await disputeTransaction.runTransaction(async () => { + const tx = await disputeInvoice(address, invoice.id, reasonHash); + return submitSignedTransaction({ tx, signTx }); + }); + setInvoice({ ...invoice, status: "Disputed" }); + setIsDisputeModalOpen(false); + } catch (err) { + setDisputeError(err instanceof Error ? err.message : "Failed to raise dispute."); + } + }; + if (loadState === "loading") { return (
@@ -93,6 +127,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin const isPayer = address === invoice.payer; const isPaid = invoice.status === "Paid"; + const canRaiseDispute = isPayer && invoice.status === "Funded"; return (
@@ -167,13 +202,27 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin

Settlement Complete

) : isPayer ? ( - +
+ + {canRaiseDispute && ( + + )} +
) : (
Restricted to Registered Payer @@ -187,6 +236,18 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin This is a direct settlement page. Verify all details before proceeding.

+ { + if (!disputeTransaction.isPending) { + setIsDisputeModalOpen(false); + setDisputeError(null); + } + }} + onDispute={handleDispute} + />
); } diff --git a/src/components/DisputeInvoiceModal.tsx b/src/components/DisputeInvoiceModal.tsx new file mode 100644 index 0000000..0b6d045 --- /dev/null +++ b/src/components/DisputeInvoiceModal.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import type { Invoice } from "@/utils/soroban"; +import { hashDisputeEvidence } from "@/utils/disputeEvidence"; +import { formatUSDC } from "@/utils/format"; + +interface DisputeInvoiceModalProps { + invoice: Invoice | null; + isSubmitting: boolean; + error?: string | null; + onClose: () => void; + onDispute: (reasonHash: string, evidence: string) => Promise | void; +} + +export default function DisputeInvoiceModal({ + invoice, + isSubmitting, + error, + onClose, + onDispute, +}: DisputeInvoiceModalProps) { + const [evidence, setEvidence] = useState(""); + const [validationError, setValidationError] = useState(null); + const trimmedEvidence = evidence.trim(); + + if (!invoice) return null; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!trimmedEvidence) { + setValidationError("Evidence description is required."); + return; + } + + setValidationError(null); + const reasonHash = await hashDisputeEvidence(trimmedEvidence); + await onDispute(reasonHash, trimmedEvidence); + }; + + return ( +
+
+
+
+

Dispute

+

+ Raise Dispute +

+
+ +
+ +
+
+ Invoice + #{invoice.id.toString()} +
+
+ Amount + {formatUSDC(invoice.amount)} +
+
+ + +