diff --git a/app/governance/page.tsx b/app/governance/page.tsx index a810b13..630389e 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -29,6 +29,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) { Active: { color: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30", icon: "fiber_manual_record" }, Passed: { color: "bg-primary/15 text-primary border-primary/30", icon: "check_circle" }, Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" }, + Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" }, 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" }, }; @@ -202,10 +203,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); + return () => { + window.clearTimeout(timeout); + clearInterval(interval); + }; }, [load]); useEffect(() => { diff --git a/src/components/FundConfirmModal.tsx b/src/components/FundConfirmModal.tsx index ab382b8..919a53a 100644 --- a/src/components/FundConfirmModal.tsx +++ b/src/components/FundConfirmModal.tsx @@ -11,7 +11,7 @@ import { Invoice, submitSignedTransaction, } from "@/utils/soroban"; -import { formatTokenAmount, formatDate, calculateYield } from "@/utils/format"; +import { formatTokenAmount, calculateYield } from "@/utils/format"; import { useFundInvoice } from "@/hooks/useInvoices"; import { getPayerScore, PayerScoreResult } from "@/utils/soroban"; @@ -19,6 +19,7 @@ type FundingStep = "approve" | "fund"; interface FundConfirmModalProps { invoice: Invoice | null; + payerScore?: number | null; onClose: () => void; onSuccess: () => void; payerScore?: PayerScoreResult | null; @@ -73,7 +74,8 @@ export default function FundConfirmModal({ invoice, onClose, onSuccess, payerSco useEffect(() => { if (!invoice || !address) return; - void refreshAllowance(invoice, address); + const timeout = window.setTimeout(() => void refreshAllowance(invoice, address), 0); + return () => window.clearTimeout(timeout); }, [address, refreshAllowance, invoice]); if (!invoice) return null; @@ -136,6 +138,11 @@ export default function FundConfirmModal({ invoice, onClose, onSuccess, payerSco }; const tokenSymbol = selectedInvoiceToken?.symbol ?? "USDC"; + const yieldAmount = calculateYield(invoice.amount, invoice.discount_rate); + const daysToDue = Math.max( + 0, + Math.ceil((Number(invoice.due_date) * 1000 - referenceTimeMs) / (24 * 60 * 60 * 1000)), + ); return (
@@ -305,12 +312,14 @@ export default function FundConfirmModal({ invoice, onClose, onSuccess, payerSco
- Your yield (discount): + Gross yield: {selectedInvoiceToken ? (
- {formatTokenAmount(calculateYield(invoice.amount, invoice.discount_rate), selectedInvoiceToken)} {selectedInvoiceToken.symbol} - {(invoice.discount_rate / 100).toFixed(2)}% + {formatTokenAmount(yieldAmount, selectedInvoiceToken)} + + {invoice.discount_rate} bps / {(invoice.discount_rate / 100).toFixed(2)}% +
) : null}
diff --git a/src/components/LPDashboard.tsx b/src/components/LPDashboard.tsx index da5df7b..7f3ec86 100644 --- a/src/components/LPDashboard.tsx +++ b/src/components/LPDashboard.tsx @@ -791,6 +791,7 @@ export default function LPDashboard() { {/* Confirmation Modal */} setSelectedInvoice(null)} onSuccess={() => { setSelectedInvoice(null); diff --git a/src/components/__tests__/FundConfirmModal.test.tsx b/src/components/__tests__/FundConfirmModal.test.tsx index 59cbe53..5390b51 100644 --- a/src/components/__tests__/FundConfirmModal.test.tsx +++ b/src/components/__tests__/FundConfirmModal.test.tsx @@ -28,13 +28,14 @@ const getTokenAllowance = vi.fn(); const buildApproveTokenTransaction = vi.fn(); const fundInvoice = vi.fn(); const submitSignedTransaction = vi.fn(); +const getPayerScoresBatch = vi.fn(); // ─── Module mocks ───────────────────────────────────────────────────────────── vi.mock("../../hooks/useInvoices", () => ({ useInvoices: vi.fn(), useFundInvoice: vi.fn(() => ({ - mutate: vi.fn((id, { onSuccess, onError }) => { + mutate: vi.fn(() => { // Manual trigger for testing }), isPending: false, @@ -69,7 +70,7 @@ vi.mock("../../utils/soroban", () => ({ buildApproveTokenTransaction: (...args: unknown[]) => buildApproveTokenTransaction(...args), fundInvoice: (...args: unknown[]) => fundInvoice(...args), submitSignedTransaction: (...args: unknown[]) => submitSignedTransaction(...args), - getPayerScoresBatch: vi.fn().mockResolvedValue(new Map()), + getPayerScoresBatch: (...args: unknown[]) => getPayerScoresBatch(...args), })); vi.mock("../../hooks/useApprovedTokens", () => ({ @@ -115,7 +116,7 @@ describe("FundConfirmModal (via LPDashboard)", () => { beforeEach(() => { addToast.mockClear(); updateToast.mockClear(); - (useInvoices as any).mockReturnValue({ + vi.mocked(useInvoices).mockReturnValue({ data: [mockInvoice], isLoading: false, dataUpdatedAt: Date.now(), @@ -124,6 +125,18 @@ describe("FundConfirmModal (via LPDashboard)", () => { buildApproveTokenTransaction.mockReset(); fundInvoice.mockReset(); submitSignedTransaction.mockReset(); + getPayerScoresBatch.mockResolvedValue( + new Map([ + [ + mockInvoice.payer, + { + score: 82, + settled_on_time: 12, + defaults: 1, + }, + ], + ]), + ); }); // ── Yield calculation display ───────────────────────────────────────────── @@ -149,9 +162,7 @@ describe("FundConfirmModal (via LPDashboard)", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Fund" })); - await waitFor(() => - expect(screen.getByText(/Fund Invoice #7/i)).toBeInTheDocument(), - ); + await waitFor(() => expect(screen.getByRole("button", { name: "Fund Now" })).toBeInTheDocument()); expect(screen.getByText(/970/i)).toBeInTheDocument(); }); @@ -162,9 +173,7 @@ describe("FundConfirmModal (via LPDashboard)", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Fund" })); - await waitFor(() => - expect(screen.getByText(/Fund Invoice #7/i)).toBeInTheDocument(), - ); + await waitFor(() => expect(screen.getByRole("button", { name: "Fund Now" })).toBeInTheDocument()); // 1000 × 3% = 30 USDC → 300_000_000 stroops → "30 USDC" expect(screen.getAllByText(/30 USDC/i)[0]).toBeInTheDocument(); @@ -184,20 +193,18 @@ describe("FundConfirmModal (via LPDashboard)", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Fund" })); - await waitFor(() => - expect(screen.getByRole("button", { name: "Fund Invoice" })).toBeInTheDocument(), - ); + await waitFor(() => expect(screen.getByRole("button", { name: "Fund Now" })).toBeInTheDocument()); expect(screen.queryByRole("button", { name: "Approve USDC" })).not.toBeInTheDocument(); }); it("calls fundInvoice and submitSignedTransaction when 'Fund Invoice' is clicked", async () => { const mutate = vi.fn(); - (useFundInvoice as any).mockReturnValue({ mutate, isPending: false }); + vi.mocked(useFundInvoice).mockReturnValue({ mutate, isPending: false }); render(); fireEvent.click(await screen.findByRole("button", { name: "Fund" })); - fireEvent.click(await screen.findByRole("button", { name: "Fund Invoice" })); + fireEvent.click(await screen.findByRole("button", { name: "Fund Now" })); await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); expect(mutate).toHaveBeenCalledWith( @@ -207,33 +214,46 @@ describe("FundConfirmModal (via LPDashboard)", () => { }); it("fires a success toast after a successful fund call", async () => { - (useFundInvoice as any).mockReturnValue({ - mutate: vi.fn((id, { onSuccess }) => onSuccess()), + vi.mocked(useFundInvoice).mockReturnValue({ + mutate: vi.fn((_id: bigint, { onSuccess }: { onSuccess: () => void }) => onSuccess()), isPending: false }); render(); fireEvent.click(await screen.findByRole("button", { name: "Fund" })); - fireEvent.click(await screen.findByRole("button", { name: "Fund Invoice" })); + fireEvent.click(await screen.findByRole("button", { name: "Fund Now" })); // Note: useFundInvoice internal logic handles showToast now // but the component might still have its own onsuccess logic }); it("shows an error message in the modal when fundInvoice rejects", async () => { - (useFundInvoice as any).mockReturnValue({ - mutate: vi.fn((id, { onError }) => onError(new Error("Contract revert: insufficient balance"))), + vi.mocked(useFundInvoice).mockReturnValue({ + mutate: vi.fn((_id: bigint, { onError }: { onError: (error: Error) => void }) => + onError(new Error("Contract revert: insufficient balance")), + ), isPending: false }); render(); fireEvent.click(await screen.findByRole("button", { name: "Fund" })); - fireEvent.click(await screen.findByRole("button", { name: "Fund Invoice" })); + fireEvent.click(await screen.findByRole("button", { name: "Fund Now" })); await waitFor(() => { expect(screen.getByText(/Contract revert: insufficient balance/)).toBeInTheDocument(); }); }); + + it("shows due-date timing, bps yield, and payer reputation before funding", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Fund" })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Fund Now" })).toBeInTheDocument()); + expect(screen.getByText("Days to due date:")).toBeInTheDocument(); + expect(screen.getByText("Payer reputation score:")).toBeInTheDocument(); + expect(screen.getByText("82/100")).toBeInTheDocument(); + expect(screen.getByText(/300 bps \/ 3\.00%/)).toBeInTheDocument(); + }); }); // ── Approve button – insufficient allowance path ────────────────────────── @@ -258,7 +278,9 @@ describe("FundConfirmModal (via LPDashboard)", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Fund" })); - fireEvent.click(await screen.findByRole("button", { name: "Approve USDC" })); + const approveButton = await screen.findByRole("button", { name: "Approve USDC" }); + await waitFor(() => expect(approveButton).toBeEnabled()); + fireEvent.click(approveButton); await waitFor(() => expect(buildApproveTokenTransaction).toHaveBeenCalledTimes(1)); expect(submitSignedTransaction).toHaveBeenCalledTimes(1); @@ -270,7 +292,9 @@ describe("FundConfirmModal (via LPDashboard)", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Fund" })); - fireEvent.click(await screen.findByRole("button", { name: "Approve USDC" })); + const approveButton = await screen.findByRole("button", { name: "Approve USDC" }); + await waitFor(() => expect(approveButton).toBeEnabled()); + fireEvent.click(approveButton); await waitFor(() => expect(updateToast).toHaveBeenCalledWith( @@ -285,7 +309,9 @@ describe("FundConfirmModal (via LPDashboard)", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Fund" })); - fireEvent.click(await screen.findByRole("button", { name: "Approve USDC" })); + const approveButton = await screen.findByRole("button", { name: "Approve USDC" }); + await waitFor(() => expect(approveButton).toBeEnabled()); + fireEvent.click(approveButton); await waitFor(() => { expect(screen.getByText(/User rejected tx/)).toBeInTheDocument();