@@ -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 (
+
+
+
+
+ Recent auto-invest activity
+
+
+ Investments executed automatically by your rule
+
+
+
+ {events.length === 0 ? (
+
+ No auto-investments yet. Your rule runs when a matching invoice is
+ published.
+
+ ) : (
+
+ )}
+
+
+ );
+}
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"}
+
+
+
+
+
+
-
+
+ Sectors
+
+ -
+ {formatSectors(rule.sectors)}
+
+
+
+
-
+
+ Minimum yield
+
+
-
+ {rule.minYield}%
+
+
+
+
-
+
+ Maximum invoice age
+
+ -
+ {rule.maxInvoiceAgeDays} day{rule.maxInvoiceAgeDays !== 1 ? "s" : ""}
+
+
+
+
-
+
+ Max per invoice
+
+ -
+ {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 (
+
+
+
+
+ Auto-invest
+
+
+ Connect your wallet to configure automatic investing.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Auto-invest
+
+
+ Automatically invest in invoices that match your rule.
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+
+
+ ) : isError ? (
+
+ Couldn't load your auto-invest settings.
+
+ ) : (
+ <>
+ {rule && !formOpen && (
+
+ )}
+
+ {(!rule || formOpen) && (
+
+ )}
+
+
+ >
+ )}
+
+
+
+
+ );
+}
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 (
+
+ );
+}
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({