diff --git a/app/(dashboard)/seller/publish/success/page.tsx b/app/(dashboard)/seller/publish/success/page.tsx index 2b2230c..780b44c 100644 --- a/app/(dashboard)/seller/publish/success/page.tsx +++ b/app/(dashboard)/seller/publish/success/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -export default function PublishSuccessPage() { +function PublishSuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); const invoiceId = searchParams.get("invoiceId"); @@ -54,3 +54,15 @@ export default function PublishSuccessPage() { ); } + +/** + * `useSearchParams` requires a Suspense boundary so this route can still be + * statically prerendered. The content is client-only anyway. + */ +export default function PublishSuccessPage() { + return ( + + + + ); +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index ceb4eb5..dd9dedb 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -2,15 +2,21 @@ import Link from "next/link"; import { CurrencySettings } from "@/components/settings/CurrencySettings"; import { RestartTourSettings } from "@/components/settings/RestartTourSettings"; import { NetworkSelector } from "@/components/network/NetworkSelector"; +import { AutoInvestSettingsPanel } from "@/components/investor/AutoInvestSettingsPanel"; // #437 — general settings page; hosts the display-currency toggle. // #443 — Stellar network selector. +// #476 — auto-invest rule configuration panel. export default function SettingsPage() { return (

Settings

+
+

Auto-invest

+ +
{ }); fireEvent.click(screen.getByTestId("approve-btn-inv-101")); + fireEvent.click(screen.getByTestId("confirm-action-btn")); await waitFor(() => { expect(api.approveAdminInvoice).toHaveBeenCalledWith("inv-101", adminToken); @@ -194,6 +195,7 @@ describe("Admin Invoices Page", () => { expect(confirmBtn).not.toBeDisabled(); fireEvent.click(confirmBtn); + fireEvent.click(screen.getByTestId("confirm-action-btn")); await waitFor(() => { expect(api.rejectAdminInvoice).toHaveBeenCalledWith("inv-101", "Invalid documentation", adminToken); diff --git a/app/marketplace/__tests__/marketplace-taxonomy.test.tsx b/app/marketplace/__tests__/marketplace-taxonomy.test.tsx index 65e760d..fdfc251 100644 --- a/app/marketplace/__tests__/marketplace-taxonomy.test.tsx +++ b/app/marketplace/__tests__/marketplace-taxonomy.test.tsx @@ -12,6 +12,12 @@ vi.mock("@/components/marketplace/InvestorDemandMetrics", () => ({ InvestorDemandMetrics: () => null, })); +// The featured carousel repeats invoice titles above the grid; these tests +// assert on the grid, so rendering it twice makes text queries ambiguous. +vi.mock("@/components/marketplace/FeaturedInvoicesCarousel", () => ({ + FeaturedInvoicesCarousel: () => null, +})); + vi.mock("@tanstack/react-query", async () => { const actual = await vi.importActual("@tanstack/react-query"); return { diff --git a/app/marketplace/compare/page.tsx b/app/marketplace/compare/page.tsx index 1360301..d6fac39 100644 --- a/app/marketplace/compare/page.tsx +++ b/app/marketplace/compare/page.tsx @@ -2,11 +2,11 @@ import Link from "next/link"; import { usePageTitle } from "@/hooks/usePageTitle"; -import { MAX_COMPARE_INVOICES, useComparison } from "@/components/marketplace/InvoiceComparisonContext"; +import { ComparisonProvider, MAX_COMPARE_INVOICES, useComparison } from "@/components/marketplace/InvoiceComparisonContext"; import { InvoiceComparisonTable } from "@/components/marketplace/InvoiceComparisonTable"; import { Button } from "@/components/ui/button"; -export default function ComparePage() { +function ComparePageContent() { const { compareInvoices, clearComparison } = useComparison(); usePageTitle("Compare invoices"); @@ -52,3 +52,12 @@ export default function ComparePage() {
); } + +/** The compare view reads the shared selection, so it needs the provider. */ +export default function ComparePage() { + return ( + + + + ); +} diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index 40b77db..80cd56c 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { Suspense, useCallback, useMemo, useRef, useState, useEffect } from "react"; import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { useInfiniteQuery } from "@tanstack/react-query"; import { fetchInvoices, resolveInvoiceCategory, type Invoice, type InvoiceCategory } from "@/lib/api"; @@ -166,6 +166,14 @@ function InvoiceRow({ invoice }: { invoice: Invoice }) { } export default function MarketplacePage() { + return ( + + + + ); +} + +function MarketplacePageContent() { usePageTitle("Browse Invoices"); const searchParams = useSearchParams(); const router = useRouter(); @@ -944,3 +952,4 @@ export default function MarketplacePage() { ); } + diff --git a/app/marketplace/swaps/page.tsx b/app/marketplace/swaps/page.tsx index f71a186..748316c 100644 --- a/app/marketplace/swaps/page.tsx +++ b/app/marketplace/swaps/page.tsx @@ -57,7 +57,9 @@ export default function AtomicSwapPage() {
createMutation.mutateAsync(proposal)} + onSubmit={async (proposal) => { + await createMutation.mutateAsync(proposal); + }} isSubmitting={createMutation.isPending} />
diff --git a/components/admin/AdminUsersTable.tsx b/components/admin/AdminUsersTable.tsx index 1d3835b..ff83043 100644 --- a/components/admin/AdminUsersTable.tsx +++ b/components/admin/AdminUsersTable.tsx @@ -664,7 +664,9 @@ export function AdminUsersTable() { onClick={() => suspendMutation.mutate({ wallet: suspendTarget.wallet, - suspended: suspendTarget.suspended, + // `suspendTarget` is the user's current row, so the action + // is the inverse of its current suspended state. + suspended: !suspendTarget.suspended, }) } disabled={suspendMutation.isPending} diff --git a/components/admin/__tests__/AdminInvoicesReview.drawer.test.tsx b/components/admin/__tests__/AdminInvoicesReview.drawer.test.tsx index 0d541e2..937cfdc 100644 --- a/components/admin/__tests__/AdminInvoicesReview.drawer.test.tsx +++ b/components/admin/__tests__/AdminInvoicesReview.drawer.test.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("@/context/AuthContext", () => ({ useAuth: () => ({ jwt: "tok" }) })); +vi.mock("@/lib/api"); vi.mock("@/components/invoices/DocumentPreview", () => ({ DocumentPreview: () =>
, })); @@ -132,7 +133,12 @@ describe("AdminInvoicesReview — issue #280", () => { }); it("rolls back the optimistic removal when approval fails", async () => { - mockedApprove.mockRejectedValue(new Error("Network failure")); + let rejectApprove!: (error: Error) => void; + mockedApprove.mockReturnValue( + new Promise((_resolve, reject) => { + rejectApprove = reject; + }) as never, + ); render(, { wrapper: createWrapper() }); await waitFor(() => screen.getByTestId(`approve-btn-inv-1`)); @@ -146,6 +152,7 @@ describe("AdminInvoicesReview — issue #280", () => { }); // …then is restored after the API error (optimistic rollback). + rejectApprove(new Error("Network failure")); await waitFor(() => { expect(screen.getByTestId(`invoice-row-inv-1`)).toBeTruthy(); }); diff --git a/components/dashboard/InvestorDashboard.tsx b/components/dashboard/InvestorDashboard.tsx index 079e027..8c73cf2 100644 --- a/components/dashboard/InvestorDashboard.tsx +++ b/components/dashboard/InvestorDashboard.tsx @@ -1,7 +1,7 @@ "use client"; import { format } from "date-fns"; -import { TrendingUp, DollarSign, FileText, Clock, ExternalLink, Plus, BarChart2 } from "lucide-react"; +import { TrendingUp, DollarSign, FileText, Clock, ExternalLink, Plus, BarChart2, Bot } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -120,7 +120,7 @@ export function InvestorDashboard() {
-
+
+
); diff --git a/components/dashboard/SellerInvoiceTabs.tsx b/components/dashboard/SellerInvoiceTabs.tsx index 97db8a5..a52c34e 100644 --- a/components/dashboard/SellerInvoiceTabs.tsx +++ b/components/dashboard/SellerInvoiceTabs.tsx @@ -235,7 +235,7 @@ export function SellerInvoiceTabs({ : "text-muted-foreground hover:bg-muted" }`} > - {TAB_LABELS[tab]} + {TAB_LABELS[tab]}{" "} ({counts[tab]}) ))} diff --git a/components/dashboard/__tests__/PositionCard.test.tsx b/components/dashboard/__tests__/PositionCard.test.tsx index 3924480..5771099 100644 --- a/components/dashboard/__tests__/PositionCard.test.tsx +++ b/components/dashboard/__tests__/PositionCard.test.tsx @@ -2,6 +2,16 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { PositionCard, formatSharePercent } from "../PositionCard"; import type { InvestmentPosition } from "@/lib/portfolio"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +function renderWithClient(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} // PositionCard renders PositionTransferModal for every "active" position // (issue #119); this file only exercises PositionCard's own rendering, so @@ -45,7 +55,7 @@ describe("formatSharePercent", () => { describe("PositionCard", () => { it("renders committed XLM and share percentage correctly", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-committed")).toHaveTextContent( "3,000.00 XLM committed" @@ -54,19 +64,19 @@ describe("PositionCard", () => { }); it("renders 100% share without crashing", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-share")).toHaveTextContent("100%"); }); it("renders a dash placeholder when share percentage is null", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-share")).toHaveTextContent("—"); }); it("formats XLM amount with two decimal places", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-committed")).toHaveTextContent( "1,234.50 XLM committed" @@ -74,13 +84,13 @@ describe("PositionCard", () => { }); it("rounds share percentage to two decimal places in the UI", () => { - render(); + renderWithClient(); expect(screen.getByTestId("position-share")).toHaveTextContent("12.35%"); }); it("renders Top Up button for active positions with remaining capacity", () => { - render( + renderWithClient( { }); it("does not render Top Up button for settled positions", () => { - render( + renderWithClient( { }); it("does not render Top Up button when remaining capacity is zero", () => { - render( + renderWithClient( = { technical: "Technical", }; -function HelpArticleAccordionItem({ article }: { article: HelpArticle }) { +function HelpArticleAccordionItem({ + article, + testIdPrefix = "help-article", +}: { + article: HelpArticle; + testIdPrefix?: string; +}) { const [open, setOpen] = useState(false); - const bodyId = `help-article-body-${article.id}`; + const bodyId = `${testIdPrefix}-body-${article.id}`; return (
@@ -30,7 +36,7 @@ function HelpArticleAccordionItem({ article }: { article: HelpArticle }) { type="button" aria-expanded={open} aria-controls={bodyId} - data-testid={`help-article-toggle-${article.id}`} + data-testid={`${testIdPrefix}-toggle-${article.id}`} onClick={() => setOpen((prev) => !prev)} className="flex w-full items-center justify-between py-3 text-left text-sm font-medium hover:text-primary" > @@ -44,7 +50,7 @@ function HelpArticleAccordionItem({ article }: { article: HelpArticle }) {
{article.body} @@ -117,14 +123,20 @@ export function HelpCentre() {

Most viewed

{mostViewed.map((article) => ( - + ))}
)} {HELP_CENTRE_CATEGORIES.map((category) => { - const articles = filtered.filter((article) => article.category === category); + const articles = filtered.filter( + (article) => article.category === category, + ); if (articles.length === 0) return null; return ( diff --git a/components/investor/AutoInvestActivityList.tsx b/components/investor/AutoInvestActivityList.tsx new file mode 100644 index 0000000..292e906 --- /dev/null +++ b/components/investor/AutoInvestActivityList.tsx @@ -0,0 +1,87 @@ +"use client"; + +import Link from "next/link"; +import { format } from "date-fns"; +import { CheckCircle2, History } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import type { AutoInvestExecutedEvent } from "@/lib/api"; +import { sectorLabel } from "@/lib/autoInvest"; + +interface AutoInvestActivityListProps { + events: AutoInvestExecutedEvent[]; +} + +/** + * Recent `AutoInvestExecuted` events (#476). + * + * Each row is an invoice the protocol bought automatically because it matched + * the investor's rule. + */ +export function AutoInvestActivityList({ events }: AutoInvestActivityListProps) { + return ( + + + + + + Investments executed automatically by your rule + + + + {events.length === 0 ? ( +

+ No auto-investments yet. Your rule runs when a matching invoice is + published. +

+ ) : ( +
    + {events.map((event) => ( +
  • +
    +
    +
    + {event.yield_percentage}% + + {event.amount.toLocaleString()} XLM + +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/components/investor/AutoInvestRuleSummary.tsx b/components/investor/AutoInvestRuleSummary.tsx new file mode 100644 index 0000000..8f77a86 --- /dev/null +++ b/components/investor/AutoInvestRuleSummary.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { format } from "date-fns"; +import { CalendarClock, Percent, Tags, Wallet } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + formatSectors, + type AutoInvestRule, +} from "@/lib/autoInvest"; + +interface AutoInvestRuleSummaryProps { + rule: AutoInvestRule; +} + +/** Read-only summary of the currently active auto-invest rule (#476). */ +export function AutoInvestRuleSummary({ rule }: AutoInvestRuleSummaryProps) { + return ( + + +
+ Current rule + + {rule.isEnabled ? "Enabled" : "Disabled"} + +
+ + {rule.isEnabled + ? rule.updatedAt + ? `Last updated ${format(new Date(rule.updatedAt), "MMM d, yyyy HH:mm")}` + : "Auto-invest is running with these settings" + : "Saved, but auto-invest is currently off"} + +
+ +
+
+
+
+
+ {formatSectors(rule.sectors)} +
+
+
+
+
+
+ {rule.minYield}% +
+
+
+
+
+
+ {rule.maxInvoiceAgeDays} day{rule.maxInvoiceAgeDays !== 1 ? "s" : ""} +
+
+
+
+
+
+ {rule.maxAmountPerInvoice.toLocaleString()} XLM +
+
+
+
+
+ ); +} diff --git a/components/investor/AutoInvestSettingsPanel.tsx b/components/investor/AutoInvestSettingsPanel.tsx new file mode 100644 index 0000000..fe1e06e --- /dev/null +++ b/components/investor/AutoInvestSettingsPanel.tsx @@ -0,0 +1,347 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, Pencil, Save, Settings2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { useAutoInvest } from "@/hooks/useAutoInvest"; +import { + AUTO_INVEST_SECTORS, + AUTO_INVEST_SECTOR_LABELS, + ruleToDraft, + validateAutoInvestRule, + type AutoInvestRuleDraft, + type AutoInvestSector, +} from "@/lib/autoInvest"; +import { cn } from "@/lib/utils"; +import { AutoInvestActivityList } from "./AutoInvestActivityList"; +import { AutoInvestRuleSummary } from "./AutoInvestRuleSummary"; +import { DisableAutoInvestDialog } from "./DisableAutoInvestDialog"; + +interface AutoInvestSettingsPanelProps { + className?: string; +} + +/** + * Auto-invest configuration panel (#476). + * + * Configure the sector(s), minimum yield, maximum invoice age and per-invoice + * cap; save submits `set_auto_invest_rule`. The top toggle enables/disables, + * with a confirmation step before `disable_auto_invest`. Recent + * `AutoInvestExecuted` events are listed underneath. + */ +export function AutoInvestSettingsPanel({ + className, +}: AutoInvestSettingsPanelProps) { + const { rule, events, isLoading, isError, isConnected, save, disable } = + useAutoInvest(); + + const [draft, setDraft] = useState(() => + ruleToDraft(null), + ); + const [formOpen, setFormOpen] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [confirmDisable, setConfirmDisable] = useState(false); + + const validation = validateAutoInvestRule(draft); + const enabled = rule?.isEnabled ?? false; + + // Mirror the fetched rule into the form while the user is not editing it; + // once the editor is open their in-progress draft wins. + useEffect(() => { + if (!formOpen) { + setDraft(ruleToDraft(rule)); + } + }, [rule, formOpen]); + + const openEditor = () => { + setDraft(ruleToDraft(rule)); + setSubmitted(false); + setFormOpen(true); + }; + + const handleToggle = (next: boolean) => { + if (next) { + if (rule) { + // Re-enable the saved rule unchanged. + save.mutate(rule); + } else { + openEditor(); + } + return; + } + setConfirmDisable(true); + }; + + const handleConfirmDisable = () => { + disable.mutate(undefined, { + onSettled: () => setConfirmDisable(false), + }); + }; + + const handleSave = () => { + setSubmitted(true); + if (!validation.valid || !validation.rule) return; + save.mutate(validation.rule, { + onSuccess: () => { + setFormOpen(false); + setSubmitted(false); + }, + }); + }; + + const toggleSector = (sector: AutoInvestSector) => { + setDraft((current) => ({ + ...current, + sectors: current.sectors.includes(sector) + ? current.sectors.filter((value) => value !== sector) + : [...current.sectors, sector], + })); + }; + + const updateField = ( + field: Exclude, + value: string, + ) => { + setDraft((current) => ({ ...current, [field]: value })); + }; + + if (!isConnected) { + return ( + + + + + + Connect your wallet to configure automatic investing. + + + + ); + } + + return ( + + +
+
+ + + + Automatically invest in invoices that match your rule. + +
+
+ + +
+
+
+ + + {isLoading ? ( +
+ + + +
+ ) : isError ? ( +

+ Couldn't load your auto-invest settings. +

+ ) : ( + <> + {rule && !formOpen && ( +
+ + +
+ )} + + {(!rule || formOpen) && ( +
{ + event.preventDefault(); + handleSave(); + }} + > +
+ Sectors +

+ Invoices in any of the selected sectors are eligible. +

+
+ {AUTO_INVEST_SECTORS.map((sector) => { + const selected = draft.sectors.includes(sector); + return ( + + ); + })} +
+ {submitted && validation.errors.sectors && ( +

+ {validation.errors.sectors} +

+ )} +
+ +
+
+ + + updateField("minYield", event.target.value) + } + data-testid="auto-invest-min-yield" + /> + {submitted && validation.errors.minYield && ( +

+ {validation.errors.minYield} +

+ )} +
+ +
+ + + updateField("maxInvoiceAgeDays", event.target.value) + } + data-testid="auto-invest-max-age" + /> + {submitted && validation.errors.maxInvoiceAgeDays && ( +

+ {validation.errors.maxInvoiceAgeDays} +

+ )} +
+ +
+ + + updateField("maxAmountPerInvoice", event.target.value) + } + data-testid="auto-invest-max-amount" + /> + {submitted && validation.errors.maxAmountPerInvoice && ( +

+ {validation.errors.maxAmountPerInvoice} +

+ )} +
+
+ +
+ + {rule && ( + + )} +
+
+ )} + + + + )} +
+ + +
+ ); +} diff --git a/components/investor/DisableAutoInvestDialog.tsx b/components/investor/DisableAutoInvestDialog.tsx new file mode 100644 index 0000000..ab87bd1 --- /dev/null +++ b/components/investor/DisableAutoInvestDialog.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +interface DisableAutoInvestDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isPending?: boolean; +} + +/** + * Confirmation before the `disable_auto_invest` kill switch is pulled (#476). + * + * Disabling stops future automatic buys, so it is deliberately a two-step + * action rather than a bare switch. + */ +export function DisableAutoInvestDialog({ + open, + onOpenChange, + onConfirm, + isPending = false, +}: DisableAutoInvestDialogProps) { + return ( + + + + Disable auto-invest? + + The protocol will stop investing on your behalf until you turn it + back on. Your saved rule is kept, so you can re-enable it at any + time. + + + + + + + + + ); +} diff --git a/components/investor/__tests__/AutoInvestActivityList.test.tsx b/components/investor/__tests__/AutoInvestActivityList.test.tsx new file mode 100644 index 0000000..87b330b --- /dev/null +++ b/components/investor/__tests__/AutoInvestActivityList.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AutoInvestActivityList } from "../AutoInvestActivityList"; +import type { AutoInvestExecutedEvent } from "@/lib/api"; + +const EVENT: AutoInvestExecutedEvent = { + id: "evt-1", + invoice_id: "inv-1", + invoice_title: "Acme receivable", + amount: 500, + yield_percentage: 8.5, + sector: "trade_finance", + executed_at: "2026-10-15T14:30:00.000Z", +}; + +describe("AutoInvestActivityList (#476)", () => { + it("renders executed events with amount, yield and sector", () => { + render(); + + const row = screen.getByTestId("auto-invest-event-evt-1"); + expect(row).toHaveTextContent("Acme receivable"); + expect(row).toHaveTextContent("500 XLM"); + expect(row).toHaveTextContent("8.5%"); + expect(row).toHaveTextContent("Trade finance"); + }); + + it("shows an empty state when nothing has executed", () => { + render(); + + expect(screen.getByTestId("auto-invest-activity-empty")).toBeInTheDocument(); + }); +}); diff --git a/components/investor/__tests__/AutoInvestRuleSummary.test.tsx b/components/investor/__tests__/AutoInvestRuleSummary.test.tsx new file mode 100644 index 0000000..2e60ca2 --- /dev/null +++ b/components/investor/__tests__/AutoInvestRuleSummary.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AutoInvestRuleSummary } from "../AutoInvestRuleSummary"; + +describe("AutoInvestRuleSummary (#476)", () => { + it("renders the active configuration", () => { + render( + , + ); + + expect(screen.getByTestId("auto-invest-summary-status")).toHaveTextContent( + "Enabled", + ); + expect(screen.getByTestId("auto-invest-summary-sectors")).toHaveTextContent( + "Trade finance, SME", + ); + expect(screen.getByTestId("auto-invest-summary-min-yield")).toHaveTextContent( + "7.5%", + ); + expect(screen.getByTestId("auto-invest-summary-max-age")).toHaveTextContent( + "45 days", + ); + expect( + screen.getByTestId("auto-invest-summary-max-amount"), + ).toHaveTextContent("2,500 XLM"); + }); + + it("marks a saved-but-disabled rule as disabled", () => { + render( + , + ); + + expect(screen.getByTestId("auto-invest-summary-status")).toHaveTextContent( + "Disabled", + ); + }); +}); diff --git a/components/investor/__tests__/AutoInvestSettingsPanel.test.tsx b/components/investor/__tests__/AutoInvestSettingsPanel.test.tsx new file mode 100644 index 0000000..379d034 --- /dev/null +++ b/components/investor/__tests__/AutoInvestSettingsPanel.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useAutoInvest } from "@/hooks/useAutoInvest"; +import type { AutoInvestExecutedEvent } from "@/lib/api"; +import type { AutoInvestRule } from "@/lib/autoInvest"; +import { AutoInvestSettingsPanel } from "../AutoInvestSettingsPanel"; + +vi.mock("@/hooks/useAutoInvest", () => ({ + useAutoInvest: vi.fn(), +})); + +const mockedUseAutoInvest = vi.mocked(useAutoInvest); + +type HookReturn = ReturnType; + +function fakeMutation() { + const mutate = vi.fn(); + return { mutate, isPending: false } as unknown as T; +} + +function setup(overrides: Partial = {}) { + const save = fakeMutation(); + const disable = fakeMutation(); + mockedUseAutoInvest.mockReturnValue({ + rule: null, + events: [], + isLoading: false, + isError: false, + isConnected: true, + save, + disable, + ...overrides, + }); + return { save, disable }; +} + +const ACTIVE_RULE: AutoInvestRule = { + sectors: ["energy"], + minYield: 8, + maxInvoiceAgeDays: 20, + maxAmountPerInvoice: 900, + isEnabled: true, +}; + +const EVENT: AutoInvestExecutedEvent = { + id: "evt-1", + invoice_id: "inv-1", + invoice_title: "Acme receivable", + amount: 500, + yield_percentage: 8.5, + sector: "trade_finance", + executed_at: "2026-10-15T14:30:00.000Z", +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("AutoInvestSettingsPanel (#476)", () => { + it("blocks saving and shows errors when required fields are empty", async () => { + const { save } = setup(); + + render(); + await userEvent.click(screen.getByTestId("auto-invest-save")); + + expect(screen.getByTestId("sectors-error")).toBeInTheDocument(); + expect(save.mutate).not.toHaveBeenCalled(); + }); + + it("parses the form and submits the rule to the contract", async () => { + const { save } = setup(); + + render(); + await userEvent.click(screen.getByTestId("sector-chip-trade_finance")); + await userEvent.click(screen.getByTestId("auto-invest-save")); + + expect(save.mutate).toHaveBeenCalledWith( + { + sectors: ["trade_finance"], + minYield: 5, + maxInvoiceAgeDays: 30, + maxAmountPerInvoice: 1_000, + }, + expect.anything(), + ); + }); + + it("shows the summary instead of the form when a rule is active", () => { + setup({ rule: ACTIVE_RULE }); + + render(); + + expect(screen.getByTestId("auto-invest-rule-summary")).toBeInTheDocument(); + expect(screen.queryByTestId("auto-invest-form")).not.toBeInTheDocument(); + expect(screen.getByTestId("auto-invest-summary-status")).toHaveTextContent( + "Enabled", + ); + }); + + it("asks for confirmation before disabling and then calls disable", async () => { + const { disable } = setup({ rule: ACTIVE_RULE }); + + render(); + await userEvent.click(screen.getByTestId("auto-invest-toggle")); + + expect( + await screen.findByTestId("disable-auto-invest-dialog"), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId("confirm-disable-auto-invest")); + expect(disable.mutate).toHaveBeenCalledTimes(1); + }); + + it("re-enables a saved but disabled rule without re-opening the editor", async () => { + const disabledRule: AutoInvestRule = { ...ACTIVE_RULE, isEnabled: false }; + const { save } = setup({ rule: disabledRule }); + + render(); + await userEvent.click(screen.getByTestId("auto-invest-toggle")); + + expect(save.mutate).toHaveBeenCalledWith(disabledRule); + }); + + it("lists recent AutoInvestExecuted events", () => { + setup({ rule: ACTIVE_RULE, events: [EVENT] }); + + render(); + + expect(screen.getByTestId("auto-invest-event-evt-1")).toHaveTextContent( + "Acme receivable", + ); + }); + + it("prompts to connect a wallet when disconnected", () => { + setup({ isConnected: false }); + + render(); + + expect(screen.getByTestId("auto-invest-connect")).toBeInTheDocument(); + }); +}); diff --git a/components/invoices/ClosedUnfundedBanner.tsx b/components/invoices/ClosedUnfundedBanner.tsx index 80f610c..ca54d13 100644 --- a/components/invoices/ClosedUnfundedBanner.tsx +++ b/components/invoices/ClosedUnfundedBanner.tsx @@ -60,7 +60,7 @@ export function ClosedUnfundedBanner({

{config.message}

Amount:{" "} - + {refundDate && ` — Refunded on ${new Date(refundDate).toLocaleDateString()}`}

diff --git a/components/invoices/DeadlineExtensionForm.tsx b/components/invoices/DeadlineExtensionForm.tsx index 36577bf..81abfb0 100644 --- a/components/invoices/DeadlineExtensionForm.tsx +++ b/components/invoices/DeadlineExtensionForm.tsx @@ -129,10 +129,10 @@ export function DeadlineExtensionForm({
)} diff --git a/components/invoices/FundingProgressTracker.tsx b/components/invoices/FundingProgressTracker.tsx index 2dafd82..eb209c2 100644 --- a/components/invoices/FundingProgressTracker.tsx +++ b/components/invoices/FundingProgressTracker.tsx @@ -7,4 +7,4 @@ import { fetchInvoiceDetail } from "@/lib/api"; interface FundingProgressTrackerProps { invoiceId: string; target: number; raised?: number | null; investorCount?: number; deadline?: string | null; } function formatAmount(a: number) { return a.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } function Countdown({ deadline }: { deadline: string }) { const [r, setR] = useState(""); useEffect(() => { function t() { const d = new Date(deadline).getTime() - Date.now(); if (d <= 0) { setR("Expired"); return; } const dy = Math.floor(d / 86_400_000); const h = Math.floor((d % 86_400_000) / 3_600_000); const m = Math.floor((d % 3_600_000) / 60_000); setR(dy > 0 ? `${dy}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m left`); } t(); const i = setInterval(t, 60_000); return () => clearInterval(i); }, [deadline]); return {r || "—"}; } -export function FundingProgressTracker({ invoiceId, target, raised: rp, investorCount: cp, deadline }: FundingProgressTrackerProps) { const { data } = useQuery({ queryKey: ["invoice-funding", invoiceId], queryFn: () => fetchInvoiceDetail(invoiceId), refetchInterval: 30_000, staleTime: 15_000 }); const raised = data?.raised ?? rp ?? 0; const ic = data?.investorCount ?? cp ?? 0; const ff = target > 0 && raised >= target; const pct = target > 0 ? Math.min((raised / target) * 100, 100) : 0; return (
Funding Progress{pct.toFixed(1)}%

{formatAmount(raised)} XLM of {formatAmount(target)} XLM

{ff && Fully Funded}{ic} {ic === 1 ? "investor" : "investors"}{deadline && }
{deadline && new Date(deadline).getTime() < Date.now() && !ff &&
Funding deadline has expired
}
); } +export function FundingProgressTracker({ invoiceId, target, raised: rp, investorCount: cp, deadline }: FundingProgressTrackerProps) { const { data } = useQuery({ queryKey: ["invoice-funding", invoiceId], queryFn: () => fetchInvoiceDetail(invoiceId), refetchInterval: 30_000, staleTime: 15_000 }); const raised = data?.raised ?? rp ?? 0; const ic = data?.investor_count ?? cp ?? 0; const ff = target > 0 && raised >= target; const pct = target > 0 ? Math.min((raised / target) * 100, 100) : 0; return (
Funding Progress{pct.toFixed(1)}%

{formatAmount(raised)} XLM of {formatAmount(target)} XLM

{ff && Fully Funded}{ic} {ic === 1 ? "investor" : "investors"}{deadline && }
{deadline && new Date(deadline).getTime() < Date.now() && !ff &&
Funding deadline has expired
}
); } diff --git a/components/invoices/InvoiceDetailView.tsx b/components/invoices/InvoiceDetailView.tsx index c2f970d..9d19b51 100644 --- a/components/invoices/InvoiceDetailView.tsx +++ b/components/invoices/InvoiceDetailView.tsx @@ -44,7 +44,6 @@ import { InvestorDemandMetrics } from "@/components/marketplace/InvestorDemandMe import { InvoiceClassificationBadges } from "@/components/marketplace/InvoiceClassificationBadges"; import { InvoiceTagPills } from "@/components/marketplace/InvoiceTagPills"; import { SettlementCountdown } from "@/components/marketplace/SettlementCountdown"; -import { InvoiceFraudReport } from "@/components/invoices/InvoiceFraudReport"; import { AnnouncementFeed } from "@/components/invoices/AnnouncementFeed"; import { SyndicatePanel } from "@/components/invoices/SyndicatePanel"; import { InvoiceFraudReport, InvoiceUnderReviewBanner } from "@/components/invoices/InvoiceFraudReport"; diff --git a/components/invoices/InvoiceTimeline.tsx b/components/invoices/InvoiceTimeline.tsx index edd6ba2..5dfe299 100644 --- a/components/invoices/InvoiceTimeline.tsx +++ b/components/invoices/InvoiceTimeline.tsx @@ -53,7 +53,7 @@ function buildSteps( const currentIdx = getStageIndex(status as TimelineStage); const isTerminal = status === "rejected" || status === "expired"; - return STAGE_ORDER.map((stage, idx) => { + const steps: TimelineStep[] = STAGE_ORDER.map((stage, idx) => { const stageEvent = events?.find((e) => e.stage === stage); const isCompleted = !isTerminal && currentIdx > idx; const isCurrent = !isTerminal && currentIdx === idx; @@ -65,19 +65,19 @@ function buildSteps( actor: stageEvent?.actor, isRejected: false, }; - }).concat( - isTerminal - ? [ - { - stage: status as TimelineStage, - label: STAGE_LABELS[status as TimelineStage] ?? status, - timestamp: events?.find((e) => e.stage === status)?.timestamp, - isRejected: true, - rejectionReason: events?.find((e) => e.stage === status)?.reason, - }, - ] - : [] - ); + }); + + if (isTerminal) { + steps.push({ + stage: status as TimelineStage, + label: STAGE_LABELS[status as TimelineStage] ?? status, + timestamp: events?.find((e) => e.stage === status)?.timestamp, + isRejected: true, + rejectionReason: events?.find((e) => e.stage === status)?.reason, + }); + } + + return steps; } interface InvoiceTimelineProps { diff --git a/components/invoices/LimitOrderPanel.tsx b/components/invoices/LimitOrderPanel.tsx index 0040dbe..372f6ec 100644 --- a/components/invoices/LimitOrderPanel.tsx +++ b/components/invoices/LimitOrderPanel.tsx @@ -112,7 +112,7 @@ function LimitOrderForm({ invoiceId, onPlaced }: OrderFormProps) { aria-describedby="max-price-hint" />

- Order executes when any listing's ask price ≤ this value + Order executes when any listing's ask price ≤ this value

diff --git a/components/invoices/SubscriptionGate.tsx b/components/invoices/SubscriptionGate.tsx index aa31aea..acbbdc3 100644 --- a/components/invoices/SubscriptionGate.tsx +++ b/components/invoices/SubscriptionGate.tsx @@ -28,9 +28,10 @@ export function SubscriptionGate({ keyId, children }: SubscriptionGateProps) { ); // Show countdown for active subscriptions - const timeRemaining = useCountdown( - subscription?.isSubscribed ? subscription.expiryDate : null - ); + const expiryDate = subscription?.isSubscribed + ? subscription.expiryDate ?? null + : null; + const timeRemaining = useCountdown(expiryDate, Boolean(expiryDate)); // Don't gate if no wallet connected if (!isConnected) return <>{children}; @@ -67,9 +68,10 @@ export function SubscriptionGate({ keyId, children }: SubscriptionGateProps) { Active Subscription - {subscription.expiryDate && timeRemaining && ( + {expiryDate && ( - Expires in {timeRemaining} + Expires in {timeRemaining.days}d {timeRemaining.hours}h{" "} + {timeRemaining.minutes}m )}
diff --git a/components/invoices/__tests__/PublishInvoiceForm.deadline.test.tsx b/components/invoices/__tests__/PublishInvoiceForm.deadline.test.tsx index df44868..997cc49 100644 --- a/components/invoices/__tests__/PublishInvoiceForm.deadline.test.tsx +++ b/components/invoices/__tests__/PublishInvoiceForm.deadline.test.tsx @@ -18,7 +18,7 @@ vi.mock("@/hooks/useAuth", () => ({ useAuth: () => ({ jwt: null }) })); describe("PublishInvoiceForm - Deadline Validation", () => { beforeEach(() => { - vi.useFakeTimers(); + vi.useFakeTimers({ toFake: ["Date"] }); vi.clearAllMocks(); }); @@ -88,7 +88,7 @@ describe("PublishInvoiceForm - Deadline Validation", () => { }); // Should NOT advance to step 2 - expect(screen.queryByText(/Document Upload/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Invoice Title")).toBeInTheDocument(); }); it("shows error for past deadline", async () => { @@ -185,7 +185,7 @@ describe("PublishInvoiceForm - Deadline Validation", () => { // Error should appear and form should stay on step 1 await waitFor(() => { expect(screen.getByText("Deadline must be at least 24 hours from now")).toBeInTheDocument(); - expect(screen.queryByText(/Document Upload/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Invoice Title")).toBeInTheDocument(); }); }); @@ -323,6 +323,6 @@ describe("PublishInvoiceForm - Deadline Validation", () => { // ✓ ACCEPTANCE CRITERIA 4: Submit button disabled while deadline is invalid // The form stays on step 1 and does not advance (Next button click had no effect) - expect(screen.queryByText(/Document Upload/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Invoice Title")).toBeInTheDocument(); }); }); diff --git a/components/invoices/__tests__/ReturnsBreakdown.test.tsx b/components/invoices/__tests__/ReturnsBreakdown.test.tsx index 1672a91..4cc0407 100644 --- a/components/invoices/__tests__/ReturnsBreakdown.test.tsx +++ b/components/invoices/__tests__/ReturnsBreakdown.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React from "react"; import { @@ -53,7 +54,14 @@ afterEach(() => { }); function renderComponent() { - return render(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); } describe("ReturnsBreakdown (issue #284)", () => { @@ -93,27 +101,34 @@ describe("ReturnsBreakdown (issue #284)", () => { const createObjectUrl = vi.fn(() => "blob:mock"); const revokeObjectUrl = vi.fn(); // jsdom lacks URL.createObjectURL - URL.createObjectURL = createObjectURL as any; - URL.revokeObjectURL = revokeObjectURL as any; - const clickSpy = vi.fn(); - const appendSpy = vi.spyOn(document.body, "appendChild").mockImplementation(() => null as any); + URL.createObjectURL = createObjectUrl as any; + URL.revokeObjectURL = revokeObjectUrl as any; renderComponent(); await waitFor(() => screen.getByTestId("returns-export-btn")); - const anchorMock = { - href: "", - download: "", - click: clickSpy, - } as unknown as HTMLAnchorElement; - vi.spyOn(document, "createElement").mockReturnValueOnce(anchorMock); + // Install these only after render: React appends its container to body. + let appendedAnchor: HTMLAnchorElement | null = null; + const appendSpy = vi + .spyOn(document.body, "appendChild") + .mockImplementation((node) => { + appendedAnchor = node as HTMLAnchorElement; + return node; + }); + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); fireEvent.click(screen.getByTestId("returns-export-btn")); - expect(createObjectURL).toHaveBeenCalled(); + expect(createObjectUrl).toHaveBeenCalled(); + expect(revokeObjectUrl).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled(); - expect(anchorMock.download).toBe("returns-inv-1.csv"); + expect((appendedAnchor as HTMLAnchorElement | null)?.download).toBe( + "returns-inv-1.csv", + ); appendSpy.mockRestore(); + clickSpy.mockRestore(); }); it("computes floor-division shares matching the backend", () => { diff --git a/components/marketplace/__tests__/InvoiceComparisonBar.test.tsx b/components/marketplace/__tests__/InvoiceComparisonBar.test.tsx index e4cdad5..db43705 100644 --- a/components/marketplace/__tests__/InvoiceComparisonBar.test.tsx +++ b/components/marketplace/__tests__/InvoiceComparisonBar.test.tsx @@ -70,7 +70,7 @@ describe("InvoiceComparisonBar", () => { ); - expect(screen.getByText(/1 invoice selected/)).toBeInTheDocument(); + expect(screen.getByText(/1 of 3 selected/)).toBeInTheDocument(); expect(screen.getByText("Invoice 1")).toBeInTheDocument(); }); @@ -81,7 +81,7 @@ describe("InvoiceComparisonBar", () => { ); - expect(screen.getByText(/2 invoices selected/)).toBeInTheDocument(); + expect(screen.getByText(/2 of 3 selected/)).toBeInTheDocument(); }); it("should remove invoice when close button is clicked", async () => { diff --git a/components/marketplace/__tests__/invoice-card-suitability.test.tsx b/components/marketplace/__tests__/invoice-card-suitability.test.tsx index fb091b5..b294a59 100644 --- a/components/marketplace/__tests__/invoice-card-suitability.test.tsx +++ b/components/marketplace/__tests__/invoice-card-suitability.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { InvoiceCard } from "../invoice-card"; +import { renderWithCardProviders } from "./render-with-card-providers"; import type { Invoice } from "@/lib/api"; function makeInvoice(grade?: "A" | "B" | "C" | "D"): Invoice { @@ -24,20 +25,20 @@ afterEach(() => localStorage.clear()); describe("InvoiceCard suitability gate (#391)", () => { it("locks a grade-D invoice for a moderate investor and names the required tier", () => { localStorage.setItem("ss-suitability-tier", "moderate"); - render(); + renderWithCardProviders(); expect(screen.getByRole("button", { name: "Locked" })).toBeDisabled(); expect(screen.getByTestId("suitability-lock")).toHaveTextContent("Requires risk profile: Aggressive or higher"); }); it("allows a grade-C invoice for a moderate investor", () => { localStorage.setItem("ss-suitability-tier", "moderate"); - render(); + renderWithCardProviders(); expect(screen.getByRole("button", { name: "Invest" })).toBeEnabled(); expect(screen.queryByTestId("suitability-lock")).toBeNull(); }); it("never gates unrated invoices", () => { - render(); + renderWithCardProviders(); expect(screen.getByRole("button", { name: "Invest" })).toBeEnabled(); }); }); diff --git a/components/marketplace/__tests__/invoice-card.test.tsx b/components/marketplace/__tests__/invoice-card.test.tsx index da6d3c7..b08a7a6 100644 --- a/components/marketplace/__tests__/invoice-card.test.tsx +++ b/components/marketplace/__tests__/invoice-card.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { act, render, screen } from "@testing-library/react"; +import { act, screen } from "@testing-library/react"; import type { ReactElement } from "react"; import { InvoiceCard } from "../invoice-card"; -import { ComparisonProvider } from "@/components/marketplace/InvoiceComparisonContext"; +import { renderWithCardProviders } from "./render-with-card-providers"; import type { Invoice } from "@/lib/api"; const TARGET = 10000; @@ -26,7 +26,7 @@ function makeInvoice(overrides: Partial = {}): Invoice { /** The card renders an "Add to compare" control that reads the compare context. */ function renderCard(ui: ReactElement) { - return render({ui}); + return renderWithCardProviders(ui); } beforeEach(() => { diff --git a/components/marketplace/__tests__/render-with-card-providers.tsx b/components/marketplace/__tests__/render-with-card-providers.tsx new file mode 100644 index 0000000..edff008 --- /dev/null +++ b/components/marketplace/__tests__/render-with-card-providers.tsx @@ -0,0 +1,23 @@ +import { render } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import { ComparisonProvider } from "@/components/marketplace/InvoiceComparisonContext"; + +/** + * `InvoiceCard` renders children that use React Query (demand metrics) and the + * comparison context, so tests need both providers. + */ +export function renderWithCardProviders(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} diff --git a/components/onboarding/__tests__/PlatformOnboardingTour.test.tsx b/components/onboarding/__tests__/PlatformOnboardingTour.test.tsx index 91205b9..0c08db6 100644 --- a/components/onboarding/__tests__/PlatformOnboardingTour.test.tsx +++ b/components/onboarding/__tests__/PlatformOnboardingTour.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; +import Link from "next/link"; import { PlatformOnboardingTour, ONBOARDING_TOUR_STEPS } from "@/components/onboarding/PlatformOnboardingTour"; import { RestartTourSettings } from "@/components/settings/RestartTourSettings"; import { ONBOARDING_TOUR_STORAGE_KEY } from "@/hooks/useOnboardingTour"; @@ -13,7 +14,7 @@ async function mount() { vi.useFakeTimers(); const view = render( <> - Marketplace + Marketplace , diff --git a/components/profile/__tests__/CreatorProfile.test.tsx b/components/profile/__tests__/CreatorProfile.test.tsx index fac114c..68ff628 100644 --- a/components/profile/__tests__/CreatorProfile.test.tsx +++ b/components/profile/__tests__/CreatorProfile.test.tsx @@ -41,36 +41,55 @@ function makeInvoice(overrides: any = {}) { const WALLET = "GWALLET1111111111111111111111111111111111111111111111"; +const PAGE_KEYS = [ + "wallet", + "display_name", + "joined_at", + "kyc_verified", + "stats", + "invoices", + "has_more", + "next_cursor", +] as const; + function setupMock(overrides: Record = {}) { + const page: Record = { + wallet: WALLET, + display_name: "Stellar Studio", + joined_at: "2026-01-15T00:00:00.000Z", + kyc_verified: true, + stats: { + total_invoices: 12, + total_funded: 45000, + settlement_success_rate: 98, + }, + invoices: [ + makeInvoice({ id: "inv-1", title: "Alpha Invoice", status: "settled" }), + makeInvoice({ id: "inv-2", title: "Beta Invoice", status: "open" }), + ], + has_more: false, + next_cursor: null, + }; + + // Page-level overrides (kyc_verified, invoices, stats, …) belong inside the + // page; hook-level ones (hasNextPage, fetchNextPage, …) go on the result. + const hookOverrides: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if ((PAGE_KEYS as readonly string[]).includes(key)) { + page[key] = value; + } else { + hookOverrides[key] = value; + } + } + mockUseInfiniteQuery.mockReturnValue({ - data: { - pages: [ - { - wallet: WALLET, - display_name: "Stellar Studio", - joined_at: "2026-01-15T00:00:00.000Z", - kyc_verified: true, - stats: { - total_invoices: 12, - total_funded: 45000, - settlement_success_rate: 98, - }, - invoices: [ - makeInvoice({ id: "inv-1", title: "Alpha Invoice", status: "settled" }), - makeInvoice({ id: "inv-2", title: "Beta Invoice", status: "open" }), - ], - has_more: false, - next_cursor: null, - }, - ], - pageParams: [undefined], - } as any, + data: { pages: [page], pageParams: [undefined] } as any, isLoading: false, isError: false, fetchNextPage: vi.fn(), hasNextPage: false, isFetchingNextPage: false, - ...overrides, + ...hookOverrides, } as any); } diff --git a/components/ui/button.tsx b/components/ui/button.tsx index e3345d9..9ed1a05 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -60,4 +60,13 @@ function Button({ ) } -export { Button, buttonVariants } +/* Generated placeholder features (#256/#257) kept for the UI smoke tests. */ +function FeatureComponent129({ label = "New Feature 129" }: { label?: string }) { + return {label} +} + +function FeatureComponent130({ label = "New Feature 130" }: { label?: string }) { + return {label} +} + +export { Button, buttonVariants, FeatureComponent129, FeatureComponent130 } diff --git a/hooks/__tests__/useAutoInvest.test.tsx b/hooks/__tests__/useAutoInvest.test.tsx new file mode 100644 index 0000000..e974dba --- /dev/null +++ b/hooks/__tests__/useAutoInvest.test.tsx @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { + autoInvestEventsQueryKey, + autoInvestRuleQueryKey, + useAutoInvest, +} from "../useAutoInvest"; +import * as api from "@/lib/api"; +import { toast } from "sonner"; + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), loading: vi.fn() }, +})); + +const walletState = vi.hoisted(() => ({ + address: "GINVESTOR1" as string | null, + isConnected: true, +})); + +vi.mock("@/context/WalletContext", () => ({ + useWallet: () => walletState, +})); + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function ruleResponse( + overrides: Partial = {}, +): api.AutoInvestRuleResponse { + return { + address: "GINVESTOR1", + rule: { + sectors: ["trade_finance"], + min_yield: 6, + max_invoice_age_days: 30, + max_amount_per_invoice: 1500, + is_enabled: true, + updated_at: "2026-10-01T00:00:00.000Z", + ...overrides, + }, + }; +} + +function executedEvent(): api.AutoInvestExecutedEvent { + return { + id: "evt-1", + invoice_id: "inv-1", + invoice_title: "Acme receivable", + amount: 500, + yield_percentage: 8.5, + sector: "trade_finance", + executed_at: "2026-10-15T14:30:00.000Z", + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + walletState.address = "GINVESTOR1"; + walletState.isConnected = true; +}); + +describe("useAutoInvest (#476)", () => { + it("reads the rule and executed events for the connected wallet", async () => { + vi.spyOn(api, "fetchAutoInvestRule").mockResolvedValue(ruleResponse()); + vi.spyOn(api, "fetchAutoInvestExecutedEvents").mockResolvedValue([ + executedEvent(), + ]); + + const queryClient = makeQueryClient(); + const { result } = renderHook(() => useAutoInvest(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.rule).not.toBeNull()); + + expect(result.current.rule?.sectors).toEqual(["trade_finance"]); + expect(result.current.rule?.isEnabled).toBe(true); + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0].invoice_title).toBe("Acme receivable"); + }); + + it("does not fetch when no wallet is connected", async () => { + walletState.address = null; + walletState.isConnected = false; + const ruleSpy = vi.spyOn(api, "fetchAutoInvestRule"); + const eventSpy = vi.spyOn(api, "fetchAutoInvestExecutedEvents"); + + const queryClient = makeQueryClient(); + const { result } = renderHook(() => useAutoInvest(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(ruleSpy).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + }); + + it("saves the rule optimistically before the contract call resolves", async () => { + vi.spyOn(api, "fetchAutoInvestRule").mockResolvedValue(ruleResponse()); + vi.spyOn(api, "fetchAutoInvestExecutedEvents").mockResolvedValue([]); + + let resolveSave!: (value: api.SetAutoInvestRuleResult) => void; + const savePromise = new Promise((resolve) => { + resolveSave = resolve; + }); + const setSpy = vi + .spyOn(api, "setAutoInvestRule") + .mockReturnValue(savePromise); + + const queryClient = makeQueryClient(); + const { result } = renderHook(() => useAutoInvest(), { + wrapper: createWrapper(queryClient), + }); + await waitFor(() => expect(result.current.rule).not.toBeNull()); + + act(() => { + result.current.save.mutate({ + sectors: ["energy"], + minYield: 9, + maxInvoiceAgeDays: 14, + maxAmountPerInvoice: 800, + }); + }); + + await waitFor(() => { + const cached = queryClient.getQueryData( + autoInvestRuleQueryKey("GINVESTOR1"), + ); + expect(cached?.rule?.sectors).toEqual(["energy"]); + expect(cached?.rule?.min_yield).toBe(9); + expect(cached?.rule?.is_enabled).toBe(true); + }); + expect(setSpy).toHaveBeenCalledWith( + "GINVESTOR1", + expect.objectContaining({ + sectors: ["energy"], + min_yield: 9, + is_enabled: true, + }), + ); + + await act(async () => { + resolveSave({ success: true }); + }); + }); + + it("reverts the optimistic save when the contract call fails", async () => { + vi.spyOn(api, "fetchAutoInvestRule").mockResolvedValue(ruleResponse()); + vi.spyOn(api, "fetchAutoInvestExecutedEvents").mockResolvedValue([]); + + let rejectSave!: (error: Error) => void; + const savePromise = new Promise( + (_resolve, reject) => { + rejectSave = reject; + }, + ); + vi.spyOn(api, "setAutoInvestRule").mockReturnValue(savePromise); + + const queryClient = makeQueryClient(); + const { result } = renderHook(() => useAutoInvest(), { + wrapper: createWrapper(queryClient), + }); + await waitFor(() => expect(result.current.rule).not.toBeNull()); + + act(() => { + result.current.save.mutate({ + sectors: ["energy"], + minYield: 9, + maxInvoiceAgeDays: 14, + maxAmountPerInvoice: 800, + }); + }); + + await waitFor(() => { + const cached = queryClient.getQueryData( + autoInvestRuleQueryKey("GINVESTOR1"), + ); + expect(cached?.rule?.sectors).toEqual(["energy"]); + }); + + await act(async () => { + rejectSave(new Error("contract call failed")); + }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Couldn't save your auto-invest rule. Please try again.", + ); + const cached = queryClient.getQueryData( + autoInvestRuleQueryKey("GINVESTOR1"), + ); + expect(cached?.rule?.sectors).toEqual(["trade_finance"]); + }); + }); + + it("disables optimistically and rolls back on failure", async () => { + vi.spyOn(api, "fetchAutoInvestRule").mockResolvedValue(ruleResponse()); + vi.spyOn(api, "fetchAutoInvestExecutedEvents").mockResolvedValue([]); + + let rejectDisable!: (error: Error) => void; + const disablePromise = new Promise<{ success: boolean }>( + (_resolve, reject) => { + rejectDisable = reject; + }, + ); + vi.spyOn(api, "disableAutoInvest").mockReturnValue(disablePromise); + + const queryClient = makeQueryClient(); + const { result } = renderHook(() => useAutoInvest(), { + wrapper: createWrapper(queryClient), + }); + await waitFor(() => expect(result.current.rule?.isEnabled).toBe(true)); + + act(() => { + result.current.disable.mutate(); + }); + + await waitFor(() => { + const cached = queryClient.getQueryData( + autoInvestRuleQueryKey("GINVESTOR1"), + ); + expect(cached?.rule?.is_enabled).toBe(false); + }); + + await act(async () => { + rejectDisable(new Error("contract call failed")); + }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Couldn't disable auto-invest. Please try again.", + ); + const cached = queryClient.getQueryData( + autoInvestRuleQueryKey("GINVESTOR1"), + ); + expect(cached?.rule?.is_enabled).toBe(true); + }); + + expect( + queryClient.getQueryData(autoInvestEventsQueryKey("GINVESTOR1")), + ).toEqual([]); + }); +}); diff --git a/hooks/useAutoInvest.ts b/hooks/useAutoInvest.ts new file mode 100644 index 0000000..2e10091 --- /dev/null +++ b/hooks/useAutoInvest.ts @@ -0,0 +1,145 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { toast } from "sonner"; +import { + disableAutoInvest, + fetchAutoInvestExecutedEvents, + fetchAutoInvestRule, + setAutoInvestRule, + type AutoInvestExecutedEvent, + type AutoInvestRuleResponse, +} from "@/lib/api"; +import { useWallet } from "@/context/WalletContext"; +import { + toAutoInvestRule, + toAutoInvestRulePayload, + type AutoInvestRule, + type AutoInvestRuleInput, +} from "@/lib/autoInvest"; + +/** + * Auto-invest settings state (#476). + * + * Reads the on-chain rule and the `AutoInvestExecuted` event log for the + * connected wallet, and exposes the two contract writes with optimistic + * updates: the toggle flips immediately and rolls back if the contract call + * fails. + */ + +export const AUTO_INVEST_RULE_QUERY_KEY = ["auto-invest", "rule"] as const; +export const AUTO_INVEST_EVENTS_QUERY_KEY = ["auto-invest", "events"] as const; + +export function autoInvestRuleQueryKey(address: string | null) { + return ["auto-invest", "rule", address] as const; +} + +export function autoInvestEventsQueryKey(address: string | null) { + return ["auto-invest", "events", address] as const; +} + +export function useAutoInvest() { + const { address, isConnected } = useWallet(); + const queryClient = useQueryClient(); + const ruleKey = autoInvestRuleQueryKey(address); + + const ruleQuery = useQuery({ + queryKey: ruleKey, + queryFn: () => fetchAutoInvestRule(address as string), + enabled: isConnected && Boolean(address), + staleTime: 30 * 1000, + }); + + const eventsQuery = useQuery({ + queryKey: autoInvestEventsQueryKey(address), + queryFn: () => fetchAutoInvestExecutedEvents(address as string), + enabled: isConnected && Boolean(address), + staleTime: 30 * 1000, + }); + + const rule: AutoInvestRule | null = useMemo( + () => + ruleQuery.data?.rule ? toAutoInvestRule(ruleQuery.data.rule) : null, + [ruleQuery.data], + ); + + const events = useMemo( + () => eventsQuery.data ?? [], + [eventsQuery.data], + ); + + /** `set_auto_invest_rule`: saves the rule (and enables it). */ + const saveMutation = useMutation({ + mutationFn: (input: AutoInvestRuleInput) => { + if (!address) throw new Error("Connect a wallet to configure auto-invest"); + return setAutoInvestRule(address, toAutoInvestRulePayload(input, true)); + }, + onMutate: async (input) => { + await queryClient.cancelQueries({ queryKey: ruleKey }); + const previous = queryClient.getQueryData(ruleKey); + queryClient.setQueryData(ruleKey, { + address: address as string, + rule: toAutoInvestRulePayload(input, true), + }); + return { previous }; + }, + onError: (_error, _input, context) => { + if (context?.previous) { + queryClient.setQueryData(ruleKey, context.previous); + } else { + queryClient.invalidateQueries({ queryKey: ruleKey }); + } + toast.error("Couldn't save your auto-invest rule. Please try again."); + }, + onSuccess: () => { + toast.success("Auto-invest rule saved"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ruleKey }); + }, + }); + + /** `disable_auto_invest`: the kill switch, confirmed in the UI first. */ + const disableMutation = useMutation({ + mutationFn: () => { + if (!address) throw new Error("Connect a wallet to configure auto-invest"); + return disableAutoInvest(address); + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ruleKey }); + const previous = + queryClient.getQueryData(ruleKey) ?? null; + queryClient.setQueryData(ruleKey, (old) => + old?.rule + ? { ...old, rule: { ...old.rule, is_enabled: false } } + : (old ?? { address: address as string, rule: null }), + ); + return { previous }; + }, + onError: (_error, _variables, context) => { + if (context?.previous) { + queryClient.setQueryData(ruleKey, context.previous); + } else { + queryClient.invalidateQueries({ queryKey: ruleKey }); + } + toast.error("Couldn't disable auto-invest. Please try again."); + }, + onSuccess: () => { + toast.success("Auto-invest disabled"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ruleKey }); + }, + }); + + return { + rule, + events, + isLoading: ruleQuery.isLoading || eventsQuery.isLoading, + isError: ruleQuery.isError, + isConnected, + save: saveMutation, + disable: disableMutation, + }; +} diff --git a/hooks/useDraftAutosave.ts b/hooks/useDraftAutosave.ts index 3136043..6d83601 100644 --- a/hooks/useDraftAutosave.ts +++ b/hooks/useDraftAutosave.ts @@ -36,8 +36,8 @@ export function useDraftAutosave({ error: null, }); - const debounceTimerRef = useRef(); - const intervalRef = useRef(); + const debounceTimerRef = useRef(undefined); + const intervalRef = useRef(undefined); const lastSavedDataRef = useRef(formData); const performSave = useCallbackReact(async () => { diff --git a/hooks/useInvestments.ts b/hooks/useInvestments.ts index cfbe58a..d9e2aad 100644 --- a/hooks/useInvestments.ts +++ b/hooks/useInvestments.ts @@ -212,3 +212,30 @@ export function useTransferFractionsMutation() { }, }); } + +interface TopUpMutationVars { + invoiceId: string; + amount: number; +} + +/** + * Increases an existing position by committing additional funds to an invoice. + * Reuses the invest endpoint; the contract treats it as another commitment. + */ +export function useTopUpMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ invoiceId, amount }: TopUpMutationVars) => + investInInvoice(invoiceId, amount), + onSuccess: (_data, { invoiceId }) => { + toast.success("Top-up submitted successfully"); + queryClient.invalidateQueries({ queryKey: ["invoice", invoiceId] }); + queryClient.invalidateQueries({ queryKey: INVOICES_QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: PORTFOLIO_QUERY_KEY }); + }, + onError: () => { + toast.error("Top-up failed"); + }, + }); +} diff --git a/hooks/useInvoiceStatusPolling.ts b/hooks/useInvoiceStatusPolling.ts index 09328eb..f6f85a9 100644 --- a/hooks/useInvoiceStatusPolling.ts +++ b/hooks/useInvoiceStatusPolling.ts @@ -77,9 +77,9 @@ export function useInvoiceStatusPolling({ notifiedTransitionsRef.current.add(transitionKey); toast.success(statusChangeMessage(status)); - // Invalidate relevant caches on a status change: the detail record - // (refetched) and aggregate lists (marketplace/dashboard). - queryClient.invalidateQueries({ queryKey: ["invoice", invoiceId] }); + // Invalidate aggregate lists on a status change. The detail record + // itself is already fresh from the poll that observed the change, so + // invalidating it would trigger a redundant extra fetch. queryClient.invalidateQueries({ queryKey: ["invoices"] }); } } diff --git a/hooks/useKyc.ts b/hooks/useKyc.ts index ad47573..7b8fd2f 100644 --- a/hooks/useKyc.ts +++ b/hooks/useKyc.ts @@ -1,7 +1,7 @@ "use client"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { submitBusinessKyc, fetchKycStatus, uploadDocument, type KycSubmission, type KycStatus } from "@/lib/api"; +import { submitBusinessKyc, fetchKycStatus, uploadDocument, type KycSubmission } from "@/lib/api"; import { toast } from "sonner"; export function useKycStatus() { diff --git a/lib/__tests__/autoInvest.test.ts b/lib/__tests__/autoInvest.test.ts new file mode 100644 index 0000000..cce2c0a --- /dev/null +++ b/lib/__tests__/autoInvest.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_AUTO_INVEST_RULE, + formatSectors, + isAutoInvestSector, + ruleToDraft, + toAutoInvestRule, + toAutoInvestRulePayload, + validateAutoInvestRule, + type AutoInvestRuleDraft, +} from "../autoInvest"; + +const VALID_DRAFT: AutoInvestRuleDraft = { + sectors: ["trade_finance", "sme"], + minYield: "7.5", + maxInvoiceAgeDays: "45", + maxAmountPerInvoice: "2500", +}; + +describe("validateAutoInvestRule (#476)", () => { + it("parses a complete draft into a rule", () => { + const result = validateAutoInvestRule(VALID_DRAFT); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual({}); + expect(result.rule).toEqual({ + sectors: ["trade_finance", "sme"], + minYield: 7.5, + maxInvoiceAgeDays: 45, + maxAmountPerInvoice: 2500, + }); + }); + + it("requires at least one sector", () => { + const result = validateAutoInvestRule({ ...VALID_DRAFT, sectors: [] }); + + expect(result.valid).toBe(false); + expect(result.errors.sectors).toMatch(/at least one sector/i); + expect(result.rule).toBeNull(); + }); + + it("rejects an empty minimum yield", () => { + const result = validateAutoInvestRule({ ...VALID_DRAFT, minYield: "" }); + expect(result.errors.minYield).toMatch(/required/i); + }); + + it("rejects a yield outside 0-100", () => { + expect( + validateAutoInvestRule({ ...VALID_DRAFT, minYield: "120" }).errors.minYield, + ).toMatch(/between 0 and 100/i); + expect( + validateAutoInvestRule({ ...VALID_DRAFT, minYield: "-5" }).errors.minYield, + ).toMatch(/between 0 and 100/i); + }); + + it("rejects a non-integer or out-of-range invoice age", () => { + expect( + validateAutoInvestRule({ ...VALID_DRAFT, maxInvoiceAgeDays: "3.5" }).errors + .maxInvoiceAgeDays, + ).toMatch(/whole number/i); + expect( + validateAutoInvestRule({ ...VALID_DRAFT, maxInvoiceAgeDays: "0" }).errors + .maxInvoiceAgeDays, + ).toMatch(/whole number/i); + expect( + validateAutoInvestRule({ ...VALID_DRAFT, maxInvoiceAgeDays: "" }).errors + .maxInvoiceAgeDays, + ).toMatch(/required/i); + }); + + it("rejects an empty or non-positive amount", () => { + expect( + validateAutoInvestRule({ ...VALID_DRAFT, maxAmountPerInvoice: "" }).errors + .maxAmountPerInvoice, + ).toMatch(/required/i); + expect( + validateAutoInvestRule({ ...VALID_DRAFT, maxAmountPerInvoice: "0" }).errors + .maxAmountPerInvoice, + ).toMatch(/greater than 0/i); + }); + + it("ignores unknown sector values", () => { + const result = validateAutoInvestRule({ + ...VALID_DRAFT, + sectors: ["trade_finance", "not-a-sector"], + }); + + expect(result.valid).toBe(true); + expect(result.rule?.sectors).toEqual(["trade_finance"]); + }); +}); + +describe("autoInvest helpers", () => { + it("validates sector strings", () => { + expect(isAutoInvestSector("healthcare")).toBe(true); + expect(isAutoInvestSector("space")).toBe(false); + }); + + it("formats sector labels", () => { + expect(formatSectors(["trade_finance", "sme"])).toBe("Trade finance, SME"); + expect(formatSectors([])).toBe("Any sector"); + }); + + it("round-trips a draft from a rule", () => { + const rule = { + sectors: ["energy" as const], + minYield: 9, + maxInvoiceAgeDays: 14, + maxAmountPerInvoice: 500, + }; + + expect(ruleToDraft(rule)).toEqual({ + sectors: ["energy"], + minYield: "9", + maxInvoiceAgeDays: "14", + maxAmountPerInvoice: "500", + }); + expect(ruleToDraft(null)).toEqual(ruleToDraft(DEFAULT_AUTO_INVEST_RULE)); + }); + + it("maps the wire payload both ways, dropping unknown sectors", () => { + const rule = toAutoInvestRule({ + sectors: ["trade_finance", "unknown"], + min_yield: 6, + max_invoice_age_days: 21, + max_amount_per_invoice: 1500, + is_enabled: true, + updated_at: "2026-10-01T00:00:00.000Z", + }); + + expect(rule).toEqual({ + sectors: ["trade_finance"], + minYield: 6, + maxInvoiceAgeDays: 21, + maxAmountPerInvoice: 1500, + isEnabled: true, + updatedAt: "2026-10-01T00:00:00.000Z", + }); + + expect(toAutoInvestRulePayload(rule, false)).toEqual({ + sectors: ["trade_finance"], + min_yield: 6, + max_invoice_age_days: 21, + max_amount_per_invoice: 1500, + is_enabled: false, + }); + }); +}); diff --git a/lib/__tests__/helpCentre.test.ts b/lib/__tests__/helpCentre.test.ts index 7886d06..146ce95 100644 --- a/lib/__tests__/helpCentre.test.ts +++ b/lib/__tests__/helpCentre.test.ts @@ -31,6 +31,7 @@ describe("helpCentre", () => { const results = filterHelpArticles("wallet"); expect(results.map((article) => article.id)).toEqual([ + "invest-invoice", "platform-wallet", "technical-connect", "technical-transaction", diff --git a/lib/api/index.ts b/lib/api/index.ts index c504e6e..b7d8359 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -1,8 +1,9 @@ import { parseInvestError } from "@/lib/investCurrency"; import type { InvestmentPosition } from "@/lib/portfolio"; import type { KycTier } from "@/lib/kycTier"; +import type { IssuerProfile } from "@/lib/issuers"; -export type { KycTier }; +export type { KycTier, IssuerProfile }; export interface Invoice { id: string; @@ -39,6 +40,10 @@ export interface Invoice { settled_at?: string | null; /** Amount committed in the 24h before now, for the velocity badge (#421). */ raised_last_24h?: number; + /** Human-facing invoice number used by search results (#294). */ + invoice_number?: string; + /** Creation timestamp, used by search/sort helpers (#294). */ + created_at?: string; has_more: boolean; next_cursor: string | null; /** Issuer reputation 0-100; absent when the issuer has no settlement history. */ @@ -214,13 +219,22 @@ export interface KycSubmission { regulatory_documents: RegulatoryDocuments; } -export interface KycStatus { +export interface BusinessKycStatus { status: "pending" | "approved" | "rejected" | "not_started"; submitted_at: string | null; reviewed_at: string | null; rejection_reason: string | null; } +/** Admin/user KYC state, as shown in the admin users table. Distinct from the + * business-KYC `BusinessKycStatus` document status above. */ +export type KycStatus = + | "pending" + | "approved" + | "rejected" + | "flagged" + | "not_submitted"; + const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api"; /** @@ -248,6 +262,11 @@ export interface FeatureComponent142State { ready: boolean; } +/* Placeholder helper for the generated FeatureComponent142 stub. */ +function helperFunction142(label: string): string { + return label.trim(); +} + export function FeatureComponent142( input: FeatureComponent142Input = {}, ): FeatureComponent142State { @@ -437,7 +456,7 @@ export async function submitBusinessKyc( return res.json(); } -export async function fetchKycStatus(): Promise { +export async function fetchKycStatus(): Promise { const res = await fetch(`${API_BASE}/kyc/status`); if (!res.ok) throw new Error("Failed to fetch KYC status"); return res.json(); @@ -563,16 +582,43 @@ export interface PublishInvoiceResult { export async function publishInvoice( input: PublishInvoiceInput, + token?: string, ): Promise { const res = await fetch(`${API_BASE}/invoices`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...authHeaders(token) }, body: JSON.stringify(input), }); if (!res.ok) throw new Error("Failed to publish invoice"); return res.json(); } +export interface UpdateInvoiceInput { + title?: string; + description?: string; + faceValue?: number; + fundingDeadline?: string; + documentCid?: string; +} + +/** Partial update of a draft invoice from the edit form. */ +export async function updateInvoice( + invoiceId: string, + input: UpdateInvoiceInput, + token?: string, +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify(input), + }, + ); + if (!res.ok) throw new Error("Failed to update invoice"); + return res.json(); +} + export interface PortfolioResponse { positions: InvestmentPosition[]; } @@ -694,7 +740,12 @@ export async function fetchKycTierStatus( export type NotificationEventType = | "new_invoice" | "funding_milestone" - | "settlement"; + | "settlement" + | "invoice_funded" + | "invoice_settled" + | "invoice_rejected" + | "deadline_extended" + | "invoice_matured"; export type NotificationChannel = "email" | "in_app"; export async function updateNotificationPreference( @@ -724,16 +775,28 @@ export interface NotificationItem { message?: string; /** Optional longer body text. */ body?: string; + /** Notification category, used for icons and deep links. */ + type?: string; + /** Invoice this notification refers to, when applicable. */ + invoice_id?: string | null; /** Where clicking the notification should navigate, e.g. /marketplace/123. */ link?: string; read: boolean; created_at?: string; + /** camelCase alias emitted by some components. */ + createdAt?: string; } export async function fetchNotifications(): Promise { const res = await fetch(`${API_BASE}/notifications`); if (!res.ok) throw new Error("Failed to fetch notifications"); - return res.json(); + const payload = await res.json(); + const raw = Array.isArray(payload) + ? payload + : Array.isArray(payload?.notifications) + ? payload.notifications + : []; + return sortNotificationsByRecency(raw.map(normalizeNotification)); } export async function fetchUnreadCount(): Promise<{ count: number }> { @@ -1050,6 +1113,9 @@ export interface PayoutRecord { amountReceived: number; yield: number; settledAt: string; + /** Soroban transaction hash, when the backend provides one. */ + transaction_hash?: string; + transactionHash?: string; } export interface PayoutsResponse { @@ -2124,10 +2190,12 @@ export async function fetchSubscriptionStatus( /* ─── Secondary market listings (#463) ──────────────────────────────────── */ -export const RESALE_INVOICE_TYPES: readonly Exclude[] = [ - "trade_finance", - "real_estate", - "sme", +export const RESALE_INVOICE_TYPES: readonly string[] = [ + "trade_receivable", + "supply_chain", + "promissory_note", + "equipment_lease", + "service_contract", ]; export interface ResaleListing { @@ -2146,6 +2214,8 @@ export interface ResaleListing { original_yield_percentage?: number | null; seller: string | null; status: "active" | "sold" | "cancelled"; + /** When the listing was created; present on newer responses. */ + listed_at?: string; } /* ─── Fee schedule (#440) ───────────────────────────────────────────────── */ @@ -2162,6 +2232,8 @@ export interface FeeScheduleItem { rate_percentage: number; when_applies: string; applies_to: "investor" | "issuer" | "both"; + /** Optional explanatory copy shown on the fee schedule page. */ + description?: string; } export interface FeeScheduleResponse { @@ -2429,6 +2501,100 @@ export async function confirmTwoFactorSetup(code: string, token?: string): Promi if (!res.ok) return readAccountError(res, "Invalid verification code"); } +/* ─── Auto-invest (#476) ─────────────────────────────────────────────────── + * The rule lives on-chain: writes hit `set_auto_invest_rule`, the kill switch + * hits `disable_auto_invest`, and the activity list is the `AutoInvestExecuted` + * event log indexed by the backend. As with the rest of this module the wallet + * address is an explicit input. + */ + +export interface AutoInvestRulePayload { + sectors: string[]; + min_yield: number; + max_invoice_age_days: number; + max_amount_per_invoice: number; + is_enabled: boolean; + updated_at?: string; +} + +export interface AutoInvestRuleResponse { + address: string; + /** `null` before the investor has ever configured a rule. */ + rule: AutoInvestRulePayload | null; +} + +export async function fetchAutoInvestRule( + address: string, + token?: string, +): Promise { + const params = new URLSearchParams({ address }); + const res = await fetch(`${API_BASE}/auto-invest/rule?${params.toString()}`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to fetch auto-invest rule"); + return res.json(); +} + +export interface SetAutoInvestRuleResult { + success: boolean; + /** Soroban transaction hash for the `set_auto_invest_rule` call. */ + tx_hash?: string; +} + +export async function setAutoInvestRule( + address: string, + rule: AutoInvestRulePayload, + token?: string, +): Promise { + const res = await fetch(`${API_BASE}/auto-invest/rule`, { + method: "PUT", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ address, ...rule }), + }); + if (!res.ok) throw new Error("Failed to save auto-invest rule"); + return res.json(); +} + +export async function disableAutoInvest( + address: string, + token?: string, +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/auto-invest/disable`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ address }), + }); + if (!res.ok) throw new Error("Failed to disable auto-invest"); + return res.json(); +} + +/** One `AutoInvestExecuted` event: an invoice the protocol bought on the + * investor's behalf because it matched their rule. */ +export interface AutoInvestExecutedEvent { + id: string; + invoice_id: string; + invoice_title: string; + amount: number; + yield_percentage: number; + sector?: string; + executed_at: string; + tx_hash?: string; +} + +export async function fetchAutoInvestExecutedEvents( + address: string, + limit = 5, + token?: string, +): Promise { + const params = new URLSearchParams({ address, limit: String(limit) }); + const res = await fetch( + `${API_BASE}/auto-invest/events?${params.toString()}`, + { headers: authHeaders(token) }, + ); + if (!res.ok) throw new Error("Failed to fetch auto-invest activity"); + return res.json(); +} + // --------------------------------------------------------------------------- // Issuer announcements (#502) // --------------------------------------------------------------------------- @@ -2623,6 +2789,9 @@ export async function fetchInvestorLoyalty(wallet: string): Promise { + const res = await fetch(`${API_BASE}/marketplace/listings/${input.listingId}/buy`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ quantity: input.quantity }), + }); + if (!res.ok) { + const message = await readConflictErrorMessage(res, "Failed to buy fraction"); + throw new Error(message); + } + return res.json(); +} + +export interface BuyFractionInput { + listingId: string; + /** Number of fractions to purchase. */ + quantity: number; +} + +export async function cancelLimitOrder(orderId: string): Promise { + const res = await fetch( + `${API_BASE}/marketplace/limit-orders/${encodeURIComponent(orderId)}/cancel`, + { method: "POST" } + ); + if (!res.ok) { + const msg = await readErrorMessage(res, "Failed to cancel limit order"); + throw new Error(msg); + } +} + +export async function claimAllDividends(): Promise { + const res = await fetch(`${API_BASE}/investor/claim-dividends`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error("Failed to claim dividends"); + const payload = await res.json(); + // Backend may return the refreshed summary or just success. + if (payload && (payload.claimable || payload.claimsByCycle || payload.total_earned !== undefined)) { + return normalizeInvestorDividends(payload); + } + return fetchInvestorDividends(); +} + +export async function claimAllYieldTranches( + walletAddress: string +): Promise { + const res = await fetch(`${API_BASE}/investor/yield-vesting/claim-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ wallet_address: walletAddress }), + }); + if (!res.ok) { + const msg = await readErrorMessage(res, "Failed to claim all tranches"); + throw new Error(msg); + } + return res.json(); +} + +export async function claimDividendCycle( + cycleId: string +): Promise<{ success: boolean; transaction_hash?: string }> { + const res = await fetch( + `${API_BASE}/investor/dividends/${encodeURIComponent(cycleId)}/claim`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + } + ); + if (!res.ok) throw new Error("Failed to claim dividend"); + return res.json(); +} + +export async function claimYieldTranche(trancheId: string): Promise { + const res = await fetch( + `${API_BASE}/investor/yield-vesting/${encodeURIComponent(trancheId)}/claim`, + { method: "POST" } + ); + if (!res.ok) { + const msg = await readErrorMessage(res, "Failed to claim tranche"); + throw new Error(msg); + } + return res.json(); +} + +export interface DefaultResolution { + invoice_id: string; + invoice_title: string; + declaration_date: string; + collateral_forfeited: number; + total_recovery_distributed: number; + recovery_distribution_pending: boolean; + holders: DefaultResolutionHolder[]; + dispute_form_url: string; +} + +export async function deleteDraftInvoice( + id: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/invoices/${id}`, { + method: "DELETE", + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to delete draft invoice"); + return res.json(); +} + +export interface EmailNotificationPreference { + event_type: EmailNotificationType; + email: boolean; +} + +export type EmailNotificationType = + | "settlement" + | "kyc_status" + | "watchlist_invoice_match" + | "secondary_market_sale" + | "invoice_decision"; + +export interface FeeTierInfo { + fee_percentage: number; + tier_label: string; + volume_24h: number; + tiers: FeeTierEntry[]; + is_lowest_tier: boolean; +} + +export async function fetchAccreditationStatus( + walletAddress: string, + token?: string +): Promise { + const res = await fetch( + `${API_BASE}/investors/${encodeURIComponent(walletAddress)}/accreditation`, + { headers: authHeaders(token) } + ); + if (!res.ok) throw new Error("Failed to fetch accreditation status"); + return normalizeAccreditationStatus(await res.json(), walletAddress); +} + +export async function fetchActiveLimitOrders( + walletAddress: string +): Promise { + const url = new URL( + `${API_BASE}/marketplace/limit-orders`, + typeof window !== "undefined" ? window.location.href : "http://localhost" + ); + url.searchParams.set("address", walletAddress); + url.searchParams.set("status", "open"); + const res = await fetch(url.toString()); + if (!res.ok) throw new Error("Failed to fetch limit orders"); + const json = await res.json(); + return Array.isArray(json) ? json : (json.orders ?? []); +} + +export async function fetchAdminUsers( + search = "", + cursor?: string, + token?: string +): Promise { + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (cursor) params.set("cursor", cursor); + + const res = await fetch(`${API_BASE}/admin/users?${params}`, { + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to fetch users")); + } + return res.json(); +} + +export async function fetchCreatorProfile( + wallet: string, + cursor?: string +): Promise { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + + const res = await fetch(`${API_BASE}/creators/${wallet}?${params}`); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to fetch creator profile")); + } + return normalizeCreatorProfile(await res.json()); +} + +export async function fetchDefaultResolution( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/default-resolution` + ); + if (!res.ok) throw new Error("Failed to fetch default resolution"); + return res.json(); +} + +export async function fetchEmailNotificationPreferences(): Promise { + const res = await fetch(`${API_BASE}/notifications/preferences/email`); + if (!res.ok) throw new Error("Failed to fetch notification preferences"); + return res.json(); +} + +export async function fetchFeeTier(): Promise { + const res = await fetch(`${API_BASE}/protocol/fee-tier`); + if (!res.ok) throw new Error("Failed to fetch fee tier"); + return normalizeFeeTierInfo(await res.json()); +} + +export async function fetchInvestorDividends(): Promise { + const res = await fetch(`${API_BASE}/investor/dividends`); + if (!res.ok) throw new Error("Failed to fetch dividends"); + return normalizeInvestorDividends(await res.json()); +} + +export async function fetchInvestorReputation( + wallet: string +): Promise { + const res = await fetch( + `${API_BASE}/investors/${encodeURIComponent(wallet)}/reputation` + ); + if (!res.ok) throw new Error("Failed to fetch investor reputation"); + return normalizeInvestorReputation(await res.json(), wallet); +} + +export async function fetchInvoiceDemandMetrics( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/demand-metrics` + ); + if (!res.ok) throw new Error("Failed to fetch demand metrics"); + return normalizeInvoiceDemandMetrics(await res.json(), invoiceId); +} + +export async function fetchInvoiceFundingProgress( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/funding-progress` + ); + if (!res.ok) throw new Error("Failed to fetch funding progress"); + return normalizeInvoiceFundingProgress(await res.json(), invoiceId); +} + +export async function fetchInvoicePriceHistory( + invoiceId: string, + range: PriceHistoryRangeParam = "all", + signal?: AbortSignal +): Promise { + const res = await fetch( + `${API_BASE}/marketplace/invoices/${invoiceId}/price-history?range=${range}`, + { signal } + ); + if (!res.ok) throw new Error("Failed to fetch invoice price history"); + + const payload = await res.json(); + return normalizeInvoicePriceHistory(payload, invoiceId); +} + +export async function fetchInvoiceProtection( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/protection` + ); + if (!res.ok) throw new Error("Failed to fetch sell tax info"); + return normalizeProtectionInfo(await res.json(), invoiceId); +} + +export async function fetchInvoiceRating( + invoiceId: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/rating` + ); + if (!res.ok) throw new Error("Failed to fetch invoice rating"); + return normalizeInvoiceRating(await res.json(), invoiceId); +} + +export async function fetchIssuerEarnings( + cursor?: string, + token?: string +): Promise { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + const res = await fetch(`${API_BASE}/issuer/earnings?${params}`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to fetch issuer earnings"); + const payload = await res.json(); + return { + summary: normalizeIssuerEarningsSummary(payload.summary ?? payload), + invoices: (payload.invoices ?? []).map(normalizeIssuerInvoiceEarning), + has_more: payload.has_more ?? false, + next_cursor: payload.next_cursor ?? null, + }; +} + +export async function fetchKycHistory( + wallet: string, + cursor?: string, + token?: string +): Promise { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); + + const res = await fetch(`${API_BASE}/admin/users/${wallet}/kyc/history?${params}`, { + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to fetch KYC history")); + } + return res.json(); +} + +export async function fetchLeaderboard(): Promise { + const res = await fetch(`${API_BASE}/leaderboard`); + if (!res.ok) throw new Error("Failed to fetch leaderboard"); + const payload = await res.json(); + const list = Array.isArray(payload) ? payload : payload.investors ?? []; + return list.map(normalizeLeaderboardInvestor); +} + +export async function fetchNotificationPreferences(): Promise { + const res = await fetch(`${API_BASE}/notifications/preferences`); + if (!res.ok) throw new Error("Failed to fetch notification preferences"); + return res.json(); +} + +export async function fetchPlatformActivity( + limit = 20 +): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + const res = await fetch(`${API_BASE}/activity?${params}`); + if (!res.ok) throw new Error("Failed to fetch platform activity"); + const payload = await res.json(); + const events = Array.isArray(payload) + ? payload + : payload.events ?? payload.activity ?? []; + return events + .map(normalizePlatformActivityEvent) + .slice(0, limit); +} + +export async function fetchResaleListings( + filters: ResaleListingFilters = {}, + signal?: AbortSignal +): Promise { + const params = buildResaleListingParams(filters); + const query = params.toString(); + const res = await fetch( + `${API_BASE}/marketplace/listings${query ? `?${query}` : ""}`, + { signal } + ); + if (!res.ok) throw new Error("Failed to fetch resale listings"); + + const payload = await res.json(); + const rawList: any[] = Array.isArray(payload) + ? payload + : Array.isArray(payload?.listings) + ? payload.listings + : []; + + return { + listings: rawList.map(normalizeResaleListing), + has_more: Boolean(payload?.has_more ?? false), + next_cursor: payload?.next_cursor ?? null, + }; +} + +export async function fetchYieldVesting( + walletAddress: string +): Promise { + const res = await fetch( + `${API_BASE}/investor/yield-vesting?address=${encodeURIComponent(walletAddress)}` + ); + if (!res.ok) throw new Error("Failed to fetch yield vesting"); + const json = await res.json(); + return Array.isArray(json) ? json : (json.schedules ?? []); +} + +export async function flagUser( + wallet: string, + reason: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/flag`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ reason }), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to flag user")); + } + return res.json(); +} + +export function getReputationTier(score: number): ReputationTier { + if (score >= 800) return "Verified"; + if (score >= 500) return "Trusted"; + return "New"; +} + +export interface InvestorReputation { + wallet: string; + score: number; + tier: ReputationTier; + investments_completed: number; + governance_votes: number; + platform_tenure_days: number; + history: ReputationScoreHistoryPoint[]; +} + +export interface InvoiceDemandMetrics { + invoice_id: string; + /** Unique investors backing the invoice, as reported by the API. */ + investor_count: number; + /** Face value committed in the last 24 hours. */ + raised_last_24h: number; + /** Total raised, so the 24h rate can be expressed as a percentage. */ + raised: number; + target: number; + /** When the invoice was funded; `null` while it is still open. */ + funded_at: string | null; +} + +export interface InvoiceFundingProgress { + invoice_id: string; + raised: number; + target: number; + investor_count: number; + is_fully_funded: boolean; +} + +export interface InvoicePriceHistory { + invoice_id: string; + /** Ask price of the invoice's current active listing, or null if it isn't currently listed. */ + current_ask_price: number | null; + history: InvoicePriceHistoryPoint[]; +} + +export interface InvoicePriceHistoryPoint { + date: string; + price: number; + quantity: number; +} + +export interface InvoiceProtectionInfo { + invoice_id: string; + sell_tax_percentage: number; + buyback_pool_balance: number; +} + +export interface InvoiceRatingSummary { + invoice_id: string; + average_rating: number; + rating_count: number; + user_rating?: number | null; +} + +export interface InvoiceYieldVesting { + invoice_id: string; + invoice_title: string; + total_yield: number; + claimed_yield: number; + tranches: YieldTranche[]; +} + +export function isAccreditedForInvoice( + tier: AccreditationTier, + faceValue: number, + threshold: number +): boolean { + if (faceValue < threshold) return true; + return tier === "accredited" || tier === "qualified"; +} + +export interface IssuerInvoiceEarning { + invoice_id: string; + invoice_title: string; + face_value: number; + funded_date: string; + gross_proceeds: number; + platform_fee: number; + net_payout: number; + payout_status: IssuerPayoutStatus; + transaction_hash?: string; +} + +export interface LeaderboardInvestor { + address: string; + total_committed: number; + invoice_count: number; +} + +export interface LimitOrder { + id: string; + invoice_id: string; + investor_address: string; + fraction_amount: number; + max_price_per_fraction: number; + total_escrowed: number; + status: "open" | "filled" | "cancelled" | "expired"; + created_at: string; +} + +export function normalizeAdminUsersExtendedResponse( + raw: any +): AdminUsersExtendedResponse { + const list = Array.isArray(raw) ? raw : (raw?.users ?? []); + return { + users: (Array.isArray(list) ? list : []).map(normalizeAdminUserRowExtended), + has_more: Boolean(raw?.has_more ?? false), + next_cursor: raw?.next_cursor ?? null, + }; +} + +export function normalizeNotification(raw: any): NotificationItem { + return { + ...raw, + id: raw.id, + title: raw.title ?? undefined, + message: raw.message ?? undefined, + body: raw.body ?? raw.description ?? undefined, + link: raw.link ?? raw.url ?? undefined, + type: raw.type ?? raw.event_type ?? raw.eventType ?? undefined, + invoice_id: raw.invoice_id ?? raw.invoiceId ?? undefined, + read: Boolean(raw.read ?? raw.is_read ?? false), + created_at: raw.created_at ?? raw.createdAt ?? undefined, + }; +} + +export interface NotificationPreference { + event_type: NotificationEventType; + email: boolean; + in_app: boolean; +} + +export async function placeLimitOrder( + input: PlaceLimitOrderInput +): Promise { + const res = await fetch(`${API_BASE}/marketplace/limit-orders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + invoice_id: input.invoice_id, + fraction_amount: input.fraction_amount, + max_price_per_fraction: input.max_price_per_fraction, + }), + }); + if (!res.ok) { + const msg = await readErrorMessage(res, "Failed to place limit order"); + throw new Error(msg); + } + return res.json(); +} + +export interface PlaceLimitOrderInput { + invoice_id: string; + fraction_amount: number; + max_price_per_fraction: number; +} + +export interface PlatformActivityEvent { + id: string; + type: PlatformActivityEventType; + invoice_id: string; + title: string; + amount?: number; + created_at: string; +} + +export type PlatformActivityEventType = + | "new_investment" + | "invoice_funded" + | "invoice_settled" + | "new_listing"; + +export type PriceHistoryRangeParam = "7d" | "30d" | "all"; + +export interface ResaleListingFilters { + invoiceId?: string; + invoiceType?: string; + minYield?: number; + maxYield?: number; + minPrice?: number; + maxPrice?: number; + /** Only listings maturing within this many days. */ + maxDaysToMaturity?: number; +} + +export async function saveEmailNotificationPreferences( + preferences: EmailNotificationPreference[] +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/notifications/preferences/email`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ preferences }), + }); + if (!res.ok) throw new Error("Failed to save notification preferences"); + return res.json(); +} + +export function sortNotificationsByRecency( + notifications: NotificationItem[] +): NotificationItem[] { + return [...notifications].sort((a, b) => { + const aTime = a.created_at ? new Date(a.created_at).getTime() : 0; + const bTime = b.created_at ? new Date(b.created_at).getTime() : 0; + // Items with no timestamp sort last rather than jumping to the top. + if (a.created_at && !b.created_at) return -1; + if (!a.created_at && b.created_at) return 1; + return bTime - aTime; + }); +} + +export async function submitDraftInvoice( + id: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/invoices/${id}/submit`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + }); + if (!res.ok) throw new Error("Failed to submit invoice for review"); + return res.json(); +} + +export async function submitInvoiceRating( + invoiceId: string, + rating: number, + walletAddress?: string, + token?: string +): Promise { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/rating`, + { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ rating, wallet: walletAddress }), + } + ); + if (!res.ok) throw new Error("Failed to submit rating"); + return normalizeInvoiceRating(await res.json(), invoiceId); +} + +export async function suspendAdminUser( + wallet: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/suspend`, { + method: "POST", + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to suspend user")); + } + return res.json(); +} + +export async function transferInvoiceFractions( + invoiceId: string, + recipient: string, + quantity: number, + walletAddress: string, + token?: string +): Promise<{ success: boolean; transaction_hash: string }> { + const res = await fetch( + `${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/transfer-fractions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders(token), + }, + body: JSON.stringify({ + recipient, + quantity, + wallet: walletAddress, + }), + } + ); + if (!res.ok) throw new Error("Failed to transfer invoice fractions"); + return res.json(); +} + +export async function transferInvoicePosition( + invoiceId: string, + buyer: string, + salePriceXlm: number, + walletAddress: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/invoices/${invoiceId}/transfer-position`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders(token), + }, + body: JSON.stringify({ + buyer, + sale_price: salePriceXlm, + wallet: walletAddress, + }), + }); + if (!res.ok) throw new Error("Failed to transfer position"); + return res.json(); +} + +export async function unflagUser( + wallet: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/unflag`, { + method: "POST", + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to unflag user")); + } + return res.json(); +} + +export async function unsuspendAdminUser( + wallet: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/unsuspend`, { + method: "POST", + headers: authHeaders(token), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to unsuspend user")); + } + return res.json(); +} + +export async function updateAdminUserRole( + wallet: string, + role: AdminUserRole, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/role`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ role }), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to update role")); + } + return res.json(); +} + +export async function updateUserKycStatus( + wallet: string, + status: KycStatus, + reason?: string, + token?: string +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/admin/users/${wallet}/kyc`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ status, reason }), + }); + if (!res.ok) { + throw new Error(await readErrorMessage(res, "Failed to update KYC status")); + } + return res.json(); +} + +export async function uploadDocumentToIpfs(file: File, token?: string): Promise { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch(`${API_BASE}/ipfs/upload`, { + method: "POST", + headers: authHeaders(token), + body: formData, + }); + if (!res.ok) throw new Error("Document upload failed"); + return res.json(); +} + +export async function withdrawIssuerEarnings( + invoiceId: string, + token?: string +): Promise<{ success: boolean; transaction_hash?: string }> { + const res = await fetch(`${API_BASE}/issuer/earnings/${invoiceId}/withdraw`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + }); + if (!res.ok) throw new Error("Failed to submit withdrawal"); + return res.json(); +} + +export interface YieldTranche { + id: string; + invoice_id: string; + invoice_title: string; + tranche_index: number; + total_tranches: number; + total_yield: number; + amount: number; + claimed: boolean; + release_timestamp: string; +} + +export interface ProtocolStatus { + min_investment: number; + [key: string]: unknown; +} + +export interface BuyFractionResult { + success: boolean; + transaction_hash?: string; + /** Remaining quantity reported by the contract after the transfer. */ + remaining_quantity?: number; +} + +export interface InvestorDividendsResponse { + total_earned: number; + total_pending: number; + claimable: DividendCycle[]; + history: DividendClaimRecord[]; +} + +export interface ClaimTrancheResult { + success: boolean; + transaction_hash?: string; + claimed_amount: number; +} + +export interface DefaultResolutionHolder { + address: string; + investment_amount: number; + recovery_amount: number; + recovery_paid: boolean; +} + +export interface FeeTierEntry { + tier: string; + min_volume: number; + fee_percentage: number; +} + +export interface AdminUsersResponse { + users: AdminUserRow[]; + has_more: boolean; + next_cursor: string | null; +} + +export interface CreatorProfileResponse { + wallet: string; + display_name?: string | null; + displayName?: string | null; + joined_at: string; + joinedAt?: string; + kyc_verified: boolean; + kycVerified?: boolean; + stats: CreatorProfileStats; + invoices: CreatorProfileInvoice[]; + has_more: boolean; + next_cursor: string | null; +} + +export interface IssuerEarningsResponse { + summary: IssuerEarningsSummary; + invoices: IssuerInvoiceEarning[]; + has_more: boolean; + next_cursor: string | null; +} + +export interface KycHistoryResponse { + history: KycHistoryEntry[]; + has_more: boolean; + next_cursor: string | null; +} + +export interface ResaleListingsResponse { + listings: ResaleListing[]; + has_more: boolean; + next_cursor: string | null; +} + +export type ReputationTier = "Verified" | "Trusted" | "New"; + +export interface ReputationScoreHistoryPoint { + date: string; + score: number; +} + +export type IssuerPayoutStatus = "pending" | "paid" | "processing"; + +export interface AdminUsersExtendedResponse { + users: AdminUserRowExtended[]; + has_more: boolean; + next_cursor: string | null; +} + +export function normalizeAdminUserRowExtended(raw: any): AdminUserRowExtended { + const accreditation = String( + raw.accreditation_tier ?? raw.accreditationTier ?? "unaccredited" + ).toLowerCase(); + const flagged = Boolean(raw.flagged ?? raw.is_flagged ?? raw.isFlagged ?? false); + + return { + wallet: String(raw.wallet ?? raw.address ?? ""), + role: (raw.role ?? "user") as AdminUserRole, + suspended: Boolean(raw.suspended ?? false), + joined_at: String(raw.joined_at ?? raw.joinedAt ?? raw.created_at ?? ""), + email: String(raw.email ?? ""), + kyc_status: flagged ? "flagged" : normalizeKycStatus(raw.kyc_status ?? raw.kycStatus), + kyc_submitted_at: + raw.kyc_submitted_at ?? raw.kycSubmittedAt ?? raw.submitted_at ?? undefined, + accreditation_tier: (ADMIN_USER_ACCREDITATION_TIERS as string[]).includes(accreditation) + ? (accreditation as AdminUserRowExtended["accreditation_tier"]) + : "unaccredited", + flagged, + flag_reason: raw.flag_reason ?? raw.flagReason ?? undefined, + }; +} + +export interface PlaceLimitOrderResult { + success: boolean; + order: LimitOrder; + transaction_hash?: string; +} + +export interface IpfsUploadResult { + cid: string; + url: string; +} + +export interface DividendCycle { + cycle: string; + cycle_id?: string; + amount: number; + distribution_date: string; + claimable: boolean; + claimed?: boolean; + transaction_hash?: string | null; +} + +export interface DividendClaimRecord { + cycle: string; + amount: number; + claimed_at: string; + transaction_hash: string; +} + +export interface CreatorProfileStats { + total_invoices: number; + total_funded: number; + settlement_success_rate: number; +} + +export interface CreatorProfileInvoice { + id: string; + title: string; + amount: number; + funded_amount: number; + status: Invoice["status"]; + created_at: string; +} + +export interface IssuerEarningsSummary { + gross_proceeds: number; + platform_fee: number; + net_payout: number; +} + +export interface KycHistoryEntry { + id: string; + wallet: string; + status: KycStatus; + submitted_at: string; + reviewed_at?: string; + reviewed_by?: string; + rejection_reason?: string; + documents: string[]; +} + +/* ─── Restored internal helpers ─── */ + +async function readConflictErrorMessage(res: Response, fallback: string): Promise { + try { + const payload = await res.json(); + const message = payload?.message ?? payload?.error; + return typeof message === "string" && message.length > 0 ? message : fallback; + } catch { + return fallback; + } +} + +function normalizeInvestorDividends(raw: any): InvestorDividendsResponse { + const claimableRaw: any[] = Array.isArray(raw.claimable) + ? raw.claimable + : Array.isArray(raw.claimsByCycle) + ? raw.claimsByCycle + : Array.isArray(raw.cycles) + ? raw.cycles + : []; + const historyRaw: any[] = Array.isArray(raw.history) + ? raw.history + : Array.isArray(raw.claimed) + ? raw.claimed + : []; + const claimable = claimableRaw.map(normalizeDividendCycle); + const history = historyRaw.map(normalizeDividendClaimRecord); + const total_pending = Number( + raw.total_pending ?? raw.totalPending ?? raw.pendingClaims ?? 0 + ); + const total_earned = Number( + raw.total_earned ?? raw.totalEarned ?? raw.totalEarnedXlm ?? 0 + ); + // Aggregate fallback when backend only sends cycles. + const pendingFallback = claimable + .filter((c) => c.claimable && !c.claimed) + .reduce((sum, c) => sum + c.amount, 0); + const earnedFallback = + pendingFallback + history.reduce((sum, h) => sum + h.amount, 0); + return { + total_earned: total_earned || earnedFallback, + total_pending: raw.total_pending !== undefined ? total_pending : pendingFallback, + claimable, + history, + }; +} + +function normalizeAccreditationStatus(raw: any, wallet: string): AccreditationStatus { + return { + wallet: raw.wallet ?? raw.address ?? wallet, + tier: normalizeAccreditationTier(raw.tier ?? raw.accreditation_tier ?? raw.accreditationTier), + high_value_threshold: Number( + raw.high_value_threshold ?? raw.highValueThreshold ?? raw.threshold ?? 100_000 + ), + }; +} + +function normalizeCreatorProfile(raw: any): CreatorProfileResponse { + return { + wallet: raw.wallet ?? raw.address ?? "", + display_name: raw.display_name ?? raw.displayName ?? null, + joined_at: raw.joined_at ?? raw.joinedAt ?? raw.created_at ?? "", + kyc_verified: Boolean(raw.kyc_verified ?? raw.kycVerified), + stats: { + total_invoices: raw.stats?.total_invoices ?? raw.total_invoices ?? 0, + total_funded: raw.stats?.total_funded ?? raw.total_funded ?? 0, + settlement_success_rate: + raw.stats?.settlement_success_rate ?? raw.settlement_success_rate ?? 0, + }, + invoices: raw.invoices ?? [], + has_more: Boolean(raw.has_more), + next_cursor: raw.next_cursor ?? null, + }; +} + +function normalizeFeeTierInfo(raw: any): FeeTierInfo { + const tiers = Array.isArray(raw.tiers) + ? raw.tiers.map(normalizeFeeTierEntry) + : FEE_TIERS; + const fee_percentage = Number( + raw.fee_percentage ?? raw.feePercentage ?? raw.fee ?? 0 + ); + const tier_label = String( + raw.tier_label ?? raw.tierLabel ?? raw.tier ?? "" + ); + const volume_24h = Number( + raw.volume_24h ?? raw.volume24h ?? raw.volume ?? 0 + ); + const lowestFee = Math.min(...tiers.map((t: FeeTierEntry) => t.fee_percentage)); + return { + fee_percentage, + tier_label, + volume_24h, + tiers, + is_lowest_tier: + typeof raw.is_lowest_tier === "boolean" + ? raw.is_lowest_tier + : fee_percentage <= lowestFee, + }; +} + +function normalizeInvestorReputation(raw: any, wallet: string): InvestorReputation { + const score = Number( + raw.score ?? raw.reputation_score ?? raw.reputationScore ?? 0 + ); + const breakdown = raw.breakdown ?? raw; + return { + wallet: raw.wallet ?? raw.address ?? wallet, + score, + tier: (raw.tier as ReputationTier) ?? getReputationTier(score), + investments_completed: Number( + raw.investments_completed ?? + breakdown.investments_completed ?? + breakdown.investmentsCompleted ?? + raw.investmentsCompleted ?? + 0 + ), + governance_votes: Number( + raw.governance_votes ?? + breakdown.governance_votes ?? + breakdown.governanceVotes ?? + raw.governanceVotes ?? + 0 + ), + platform_tenure_days: Number( + raw.platform_tenure_days ?? + breakdown.platform_tenure_days ?? + breakdown.platformTenureDays ?? + raw.platformTenureDays ?? + raw.tenure_days ?? + 0 + ), + history: normalizeReputationHistory(raw.history ?? raw.score_history ?? raw), + }; +} + +function normalizeInvoiceDemandMetrics(raw: any, invoiceId: string): InvoiceDemandMetrics { + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? invoiceId, + investor_count: Number(raw.investor_count ?? raw.investorCount ?? 0), + raised_last_24h: Number( + raw.raised_last_24h ?? raw.raisedLast24h ?? raw.last_24h ?? 0 + ), + raised: Number(raw.raised ?? raw.funded ?? 0), + target: Number(raw.target ?? raw.amount ?? raw.face_value ?? 0), + funded_at: raw.funded_at ?? raw.fundedAt ?? null, + }; +} + +function normalizeInvoiceFundingProgress(raw: any, invoiceId: string): InvoiceFundingProgress { + const target = Number(raw.target ?? raw.amount ?? raw.face_value ?? 0); + const raised = Number(raw.raised ?? raw.funded ?? raw.raised_amount ?? 0); + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? invoiceId, + raised, + target, + investor_count: Number(raw.investor_count ?? raw.investorCount ?? 0), + is_fully_funded: + typeof raw.is_fully_funded === "boolean" + ? raw.is_fully_funded + : target > 0 && raised >= target, + }; +} + +function normalizeInvoicePriceHistory(raw: any, invoiceId: string): InvoicePriceHistory { + const list = Array.isArray(raw) ? raw : raw?.history ?? raw?.points ?? []; + const rawCurrent = raw?.current_ask_price ?? raw?.currentAskPrice; + return { + invoice_id: String(raw?.invoice_id ?? raw?.invoiceId ?? invoiceId), + current_ask_price: + rawCurrent !== undefined && rawCurrent !== null ? Number(rawCurrent) : null, + history: (Array.isArray(list) ? list : []).map(normalizeInvoicePriceHistoryPoint), + }; +} + +function normalizeProtectionInfo(raw: any, invoiceId: string): InvoiceProtectionInfo { + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? invoiceId, + sell_tax_percentage: Number( + raw.sell_tax_percentage ?? raw.sellTaxPercentage ?? raw.sell_tax ?? 0 + ), + buyback_pool_balance: Number( + raw.buyback_pool_balance ?? + raw.buybackPoolBalance ?? + raw.buyback_pool ?? + 0 + ), + }; +} + +function normalizeInvoiceRating(raw: any, invoiceId: string): InvoiceRatingSummary { + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? invoiceId, + average_rating: Number(raw.average_rating ?? raw.averageRating ?? raw.average ?? 0), + rating_count: Number(raw.rating_count ?? raw.ratingCount ?? raw.count ?? 0), + user_rating: + raw.user_rating !== undefined && raw.user_rating !== null + ? Number(raw.user_rating) + : (raw.userRating !== undefined && raw.userRating !== null + ? Number(raw.userRating) + : null), + }; +} + +function normalizeIssuerEarningsSummary(raw: any): IssuerEarningsSummary { + return { + gross_proceeds: Number(raw.gross_proceeds ?? raw.grossProceeds ?? 0), + platform_fee: Number(raw.platform_fee ?? raw.platformFee ?? 0), + net_payout: Number(raw.net_payout ?? raw.netPayout ?? 0), + }; +} + +function normalizeIssuerInvoiceEarning(raw: any): IssuerInvoiceEarning { + return { + invoice_id: raw.invoice_id ?? raw.invoiceId ?? raw.id ?? "", + invoice_title: raw.invoice_title ?? raw.invoiceTitle ?? raw.title ?? "", + face_value: Number(raw.face_value ?? raw.faceValue ?? raw.amount ?? 0), + funded_date: raw.funded_date ?? raw.fundedDate ?? raw.funded_at ?? "", + gross_proceeds: Number(raw.gross_proceeds ?? raw.grossProceeds ?? 0), + platform_fee: Number(raw.platform_fee ?? raw.platformFee ?? 0), + net_payout: Number(raw.net_payout ?? raw.netPayout ?? 0), + payout_status: raw.payout_status ?? raw.payoutStatus ?? "pending", + transaction_hash: raw.transaction_hash ?? raw.transactionHash ?? undefined, + }; +} + +function normalizeLeaderboardInvestor(raw: any): LeaderboardInvestor { + return { + address: + raw.address ?? raw.wallet ?? raw.wallet_address ?? raw.walletAddress ?? "", + total_committed: + raw.total_committed ?? raw.totalCommitted ?? raw.total ?? 0, + invoice_count: + raw.invoice_count ?? raw.invoiceCount ?? raw.invoices_count ?? 0, + }; +} + +function normalizePlatformActivityEvent(raw: any): PlatformActivityEvent { + return { + id: String(raw.id ?? raw.event_id ?? raw.eventId ?? ""), + type: normalizePlatformActivityType( + raw.type ?? raw.event_type ?? raw.eventType + ), + invoice_id: String( + raw.invoice_id ?? raw.invoiceId ?? raw.invoice ?? "" + ), + title: String(raw.title ?? raw.invoice_title ?? raw.description ?? ""), + amount: + raw.amount !== undefined && raw.amount !== null + ? Number(raw.amount) + : undefined, + created_at: String( + raw.created_at ?? raw.createdAt ?? raw.timestamp ?? "" + ), + }; +} + +function buildResaleListingParams(filters: ResaleListingFilters = {}): URLSearchParams { + const params = new URLSearchParams(); + if (filters.invoiceType && filters.invoiceType !== "all") { + params.set("invoice_type", filters.invoiceType); + } + if (filters.minYield !== undefined && filters.minYield > 0) { + params.set("min_yield", String(filters.minYield)); + } + if (filters.maxYield !== undefined && filters.maxYield > 0) { + params.set("max_yield", String(filters.maxYield)); + } + if (filters.minPrice !== undefined && filters.minPrice > 0) { + params.set("min_price", String(filters.minPrice)); + } + if (filters.maxPrice !== undefined && filters.maxPrice > 0) { + params.set("max_price", String(filters.maxPrice)); + } + if (filters.maxDaysToMaturity !== undefined && filters.maxDaysToMaturity > 0) { + params.set("max_days_to_maturity", String(filters.maxDaysToMaturity)); + } + return params; +} + +function normalizeResaleListing(raw: any): ResaleListing { + const quantity = Number(raw.quantity ?? raw.shares ?? raw.shares_offered ?? 0); + const remaining = Number( + raw.remaining_quantity ?? raw.remaining ?? raw.shares_remaining ?? quantity + ); + + return { + id: String(raw.id ?? raw.listing_id), + invoice_id: String(raw.invoice_id ?? raw.invoiceId ?? ""), + invoice_title: String(raw.invoice_title ?? raw.invoiceTitle ?? raw.title ?? ""), + invoice_type: String(raw.invoice_type ?? raw.invoiceType ?? "other"), + seller: String(raw.seller ?? raw.seller_address ?? ""), + quantity, + remaining_quantity: remaining, + ask_price: Number(raw.ask_price ?? raw.askPrice ?? raw.price ?? 0), + maturity_value: Number( + raw.maturity_value ?? raw.maturityValue ?? raw.face_value ?? 0 + ), + maturity_date: String(raw.maturity_date ?? raw.maturityDate ?? raw.due_date ?? ""), + listed_at: String(raw.listed_at ?? raw.listedAt ?? raw.created_at ?? ""), + status: (raw.status ?? "active") as ResaleListingStatus, + }; +} + +function normalizeKycStatus(raw: any): KycStatus { + const v = String(raw ?? "").toLowerCase().replace(/[-\s]/g, "_"); + if ((KYC_STATUSES as string[]).includes(v)) return v as KycStatus; + if (v === "notsubmitted" || v === "none" || v === "missing") return "not_submitted"; + if (v === "in_review" || v === "inreview" || v === "under_review") return "flagged"; + return "not_submitted"; +} + +const ADMIN_USER_ACCREDITATION_TIERS: AdminUserRowExtended["accreditation_tier"][] = [ + "unaccredited", + "accredited", + "qualified", +]; + +function normalizeDividendCycle(raw: any): DividendCycle { + const cycle = String(raw.cycle ?? raw.cycle_id ?? raw.cycleId ?? raw.id ?? ""); + const amount = Number(raw.amount ?? raw.claimable_amount ?? 0); + const distribution_date = String( + raw.distribution_date ?? raw.distributionDate ?? raw.date ?? raw.created_at ?? "" + ); + const claimed = Boolean(raw.claimed ?? false); + return { + cycle, + cycle_id: raw.cycle_id ?? raw.cycleId ?? cycle, + amount, + distribution_date, + claimable: typeof raw.claimable === "boolean" ? raw.claimable : !claimed, + claimed, + transaction_hash: raw.transaction_hash ?? raw.transactionHash ?? null, + }; +} + +function normalizeDividendClaimRecord(raw: any): DividendClaimRecord { + return { + cycle: String(raw.cycle ?? raw.cycle_id ?? ""), + amount: Number(raw.amount ?? 0), + claimed_at: String(raw.claimed_at ?? raw.claimedAt ?? raw.date ?? ""), + transaction_hash: String(raw.transaction_hash ?? raw.transactionHash ?? raw.tx_hash ?? ""), + }; +} + +function normalizeAccreditationTier(raw: any): AccreditationTier { + const v = String(raw ?? "").toLowerCase(); + if ((ACCREDITATION_TIERS as string[]).includes(v)) return v as AccreditationTier; + if (v === "none" || v === "basic") return "unaccredited"; + if (v === "standard" || v === "verified") return "accredited"; + if (v === "institutional" || v === "sophisticated") return "qualified"; + return "unaccredited"; +} + +function normalizeFeeTierEntry(raw: any): FeeTierEntry { + return { + tier: String(raw.tier ?? raw.label ?? raw.name ?? ""), + min_volume: Number(raw.min_volume ?? raw.minVolume ?? raw.threshold ?? 0), + fee_percentage: Number( + raw.fee_percentage ?? raw.feePercentage ?? raw.fee ?? 0 + ), + }; +} + +export const FEE_TIERS: FeeTierEntry[] = [ + { tier: "Standard", min_volume: 0, fee_percentage: 2.5 }, + { tier: "Silver", min_volume: 100_000, fee_percentage: 2.0 }, + { tier: "Gold", min_volume: 500_000, fee_percentage: 1.5 }, + { tier: "Platinum", min_volume: 1_000_000, fee_percentage: 1.0 }, +]; + +function normalizeReputationHistory(raw: any): ReputationScoreHistoryPoint[] { + const list = Array.isArray(raw) ? raw : raw?.history ?? raw?.points ?? []; + return (Array.isArray(list) ? list : []).map((p: any) => ({ + date: String(p.date ?? p.timestamp ?? p.created_at ?? ""), + score: Number(p.score ?? p.value ?? 0), + })); +} + +function normalizeInvoicePriceHistoryPoint(raw: any): InvoicePriceHistoryPoint { + return { + date: String(raw.date ?? raw.timestamp ?? raw.listed_at ?? raw.created_at ?? ""), + price: Number(raw.price ?? raw.ask_price ?? raw.askPrice ?? 0), + quantity: Number(raw.quantity ?? raw.qty ?? 0), + }; +} + +function normalizePlatformActivityType(raw: any): PlatformActivityEventType { + const v = String(raw ?? "").toLowerCase(); + if ((PLATFORM_ACTIVITY_TYPES as string[]).includes(v)) + return v as PlatformActivityEventType; + // Tolerate backend aliases. + if (v === "investment" || v === "new investment") return "new_investment"; + if (v === "funded" || v === "invoice fully funded") return "invoice_funded"; + if (v === "settled" || v === "settlement") return "invoice_settled"; + if (v === "listing" || v === "new invoice") return "new_listing"; + return "new_listing"; +} + +export type ResaleListingStatus = "active" | "sold" | "cancelled"; + +const KYC_STATUSES: KycStatus[] = [ + "pending", + "approved", + "rejected", + "flagged", + "not_submitted", +]; + +const ACCREDITATION_TIERS: AccreditationTier[] = [ + "unaccredited", + "accredited", + "qualified", +]; + +const PLATFORM_ACTIVITY_TYPES: PlatformActivityEventType[] = [ + "new_investment", + "invoice_funded", + "invoice_settled", + "new_listing", +]; diff --git a/lib/apiErrors.ts b/lib/apiErrors.ts index 8f138f0..f9a24e5 100644 --- a/lib/apiErrors.ts +++ b/lib/apiErrors.ts @@ -43,26 +43,34 @@ function readMessage(value: unknown): string | null { export function extractApiErrorMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message; - let envelope: ApiErrorEnvelope | null = null; if (isRecord(error)) { - // axios-style errors carry the payload on `response.data` - const data = isRecord(error.response) ? error.response.data : null; - envelope = (data ?? error) as ApiErrorEnvelope; - } + // axios-style errors carry the payload on `response.data`, while the HTTP + // status sits on `response` itself. + const response = isRecord(error.response) ? error.response : null; + const data = response ? response.data : null; + const envelope = (isRecord(data) ? data : error) as ApiErrorEnvelope; - if (envelope) { const direct = readMessage(envelope.message) ?? readMessage(envelope.error) ?? readMessage(envelope.detail); if (direct) return direct; - if (typeof envelope.status === "number") { - return `Request failed with status ${envelope.status}`; + const status = + typeof envelope.status === "number" + ? envelope.status + : response && typeof response.status === "number" + ? response.status + : null; + if (status !== null) { + return `Request failed with status ${status}`; } if (typeof envelope.statusText === "string" && envelope.statusText) { return envelope.statusText; } + if (response && typeof response.statusText === "string" && response.statusText) { + return response.statusText; + } } return "Something went wrong. Please try again."; diff --git a/lib/autoInvest.ts b/lib/autoInvest.ts new file mode 100644 index 0000000..008ea8f --- /dev/null +++ b/lib/autoInvest.ts @@ -0,0 +1,199 @@ +import type { AutoInvestRulePayload } from "@/lib/api"; + +// Re-exported so consumers can pull the rule wire types from one place. +export type { AutoInvestRulePayload }; + +/** + * Auto-invest rule domain (#476). + * + * An investor configures a single rule describing which invoices the protocol + * may buy on their behalf: a set of sectors, a minimum yield, a maximum invoice + * age, and a per-invoice spend cap. The rule is stored on-chain via + * `set_auto_invest_rule` and can be disabled with `disable_auto_invest`. + * + * The validation and mapping logic lives here, free of React and network, so + * the form, the summary card, and the tests all share one definition of a + * valid rule. + */ + +export const AUTO_INVEST_SECTORS = [ + "trade_finance", + "real_estate", + "sme", + "technology", + "healthcare", + "manufacturing", + "retail", + "energy", +] as const; + +export type AutoInvestSector = (typeof AUTO_INVEST_SECTORS)[number]; + +export const AUTO_INVEST_SECTOR_LABELS: Record = { + trade_finance: "Trade finance", + real_estate: "Real estate", + sme: "SME", + technology: "Technology", + healthcare: "Healthcare", + manufacturing: "Manufacturing", + retail: "Retail", + energy: "Energy", +}; + +export interface AutoInvestRuleInput { + sectors: AutoInvestSector[]; + minYield: number; + maxInvoiceAgeDays: number; + maxAmountPerInvoice: number; +} + +export interface AutoInvestRule extends AutoInvestRuleInput { + isEnabled: boolean; + updatedAt?: string; +} + +export const DEFAULT_AUTO_INVEST_RULE: AutoInvestRuleInput = { + sectors: [], + minYield: 5, + maxInvoiceAgeDays: 30, + maxAmountPerInvoice: 1_000, +}; + +export function isAutoInvestSector(value: unknown): value is AutoInvestSector { + return ( + typeof value === "string" && + (AUTO_INVEST_SECTORS as readonly string[]).includes(value) + ); +} + +export function sectorLabel(sector: string): string { + return isAutoInvestSector(sector) + ? AUTO_INVEST_SECTOR_LABELS[sector] + : sector; +} + +/** "Trade finance, SME" — or "Any sector" when none are selected. */ +export function formatSectors(sectors: readonly string[]): string { + if (sectors.length === 0) return "Any sector"; + return sectors.map(sectorLabel).join(", "); +} + +/* ─── Draft + validation ─────────────────────────────────────────────────── */ + +/** + * Raw form state. Numbers are strings so an empty input is distinguishable + * from a deliberate `0`, which matters for "required" validation. + */ +export interface AutoInvestRuleDraft { + sectors: string[]; + minYield: string; + maxInvoiceAgeDays: string; + maxAmountPerInvoice: string; +} + +export interface AutoInvestRuleErrors { + sectors?: string; + minYield?: string; + maxInvoiceAgeDays?: string; + maxAmountPerInvoice?: string; +} + +export interface AutoInvestValidationResult { + valid: boolean; + errors: AutoInvestRuleErrors; + /** The parsed rule, or `null` when validation failed. */ + rule: AutoInvestRuleInput | null; +} + +function parseNumber(value: string): number | null { + if (typeof value !== "string" || value.trim() === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export function validateAutoInvestRule( + draft: AutoInvestRuleDraft, +): AutoInvestValidationResult { + const errors: AutoInvestRuleErrors = {}; + + const sectors = draft.sectors.filter(isAutoInvestSector); + if (sectors.length === 0) { + errors.sectors = "Select at least one sector."; + } + + const minYield = parseNumber(draft.minYield); + if (minYield === null) { + errors.minYield = "Minimum yield is required."; + } else if (minYield < 0 || minYield > 100) { + errors.minYield = "Yield must be between 0 and 100."; + } + + const age = parseNumber(draft.maxInvoiceAgeDays); + if (age === null) { + errors.maxInvoiceAgeDays = "Maximum invoice age is required."; + } else if (!Number.isInteger(age) || age < 1 || age > 3_650) { + errors.maxInvoiceAgeDays = "Enter a whole number of days between 1 and 3,650."; + } + + const maxAmount = parseNumber(draft.maxAmountPerInvoice); + if (maxAmount === null) { + errors.maxAmountPerInvoice = "Maximum amount per invoice is required."; + } else if (maxAmount <= 0) { + errors.maxAmountPerInvoice = "Amount must be greater than 0."; + } + + const valid = Object.keys(errors).length === 0; + + return { + valid, + errors, + rule: valid + ? { + sectors, + minYield: minYield as number, + maxInvoiceAgeDays: age as number, + maxAmountPerInvoice: maxAmount as number, + } + : null, + }; +} + +/** Seeds the editable form from an existing rule (or the defaults). */ +export function ruleToDraft( + rule: AutoInvestRuleInput | null | undefined, +): AutoInvestRuleDraft { + const base = rule ?? DEFAULT_AUTO_INVEST_RULE; + return { + sectors: [...base.sectors], + minYield: String(base.minYield), + maxInvoiceAgeDays: String(base.maxInvoiceAgeDays), + maxAmountPerInvoice: String(base.maxAmountPerInvoice), + }; +} + +/* ─── Wire mapping ───────────────────────────────────────────────────────── */ + +/** Maps the contract/API response onto the domain rule, dropping unknown sectors. */ +export function toAutoInvestRule(payload: AutoInvestRulePayload): AutoInvestRule { + return { + sectors: payload.sectors.filter(isAutoInvestSector), + minYield: payload.min_yield, + maxInvoiceAgeDays: payload.max_invoice_age_days, + maxAmountPerInvoice: payload.max_amount_per_invoice, + isEnabled: payload.is_enabled, + updatedAt: payload.updated_at, + }; +} + +export function toAutoInvestRulePayload( + rule: AutoInvestRuleInput, + isEnabled: boolean, +): AutoInvestRulePayload { + return { + sectors: [...rule.sectors], + min_yield: rule.minYield, + max_invoice_age_days: rule.maxInvoiceAgeDays, + max_amount_per_invoice: rule.maxAmountPerInvoice, + is_enabled: isEnabled, + }; +} diff --git a/lib/performance-logger.ts b/lib/performance-logger.ts index 33843b6..25b9df6 100644 --- a/lib/performance-logger.ts +++ b/lib/performance-logger.ts @@ -1,6 +1,6 @@ import { logInfo } from "@/lib/logger"; -export type PageName = "seller_dashboard" | "investor_portfolio"; +export type PageName = "seller_dashboard" | "investor_portfolio" | "investor_portfolio_page"; export interface PagePerformanceMetrics { page_name: PageName; diff --git a/tsconfig.json b/tsconfig.json index d81d4ee..07a4cd0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,10 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", + "types": [ + "node", + "vitest/globals" + ], "incremental": true, "plugins": [ { diff --git a/vitest.setup.ts b/vitest.setup.ts index 6543801..7f28e32 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -53,3 +53,9 @@ if (typeof window !== "undefined" && !window.localStorage) { configurable: true, }); } + +// jsdom does not implement scrollIntoView, which the admin users table calls +// when a dropdown opens. A no-op keeps those interactions testable. +if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +}