diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 36c5c87..525a3e1 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -3,10 +3,12 @@ import { CurrencySettings } from "@/components/settings/CurrencySettings"; import { RestartTourSettings } from "@/components/settings/RestartTourSettings"; import { NetworkSelector } from "@/components/network/NetworkSelector"; import { AutoInvestSettingsPanel } from "@/components/investor/AutoInvestSettingsPanel"; +import { RolloverSettingsPanel } from "@/components/investor/RolloverSettingsPanel"; // #437 — general settings page; hosts the display-currency toggle. // #443 — Stellar network selector. // #476 — auto-invest rule configuration panel. +// #545 — invoice auto-rollover configuration panel. export default function SettingsPage() { return (
@@ -17,6 +19,10 @@ export default function SettingsPage() {

Auto-invest

+
+

Auto-rollover

+ +

Alerts

Profile

+
+ +
+
+

+ Accreditation +

+ {accreditation ? ( +
+ + {accreditationExpiry && ( +

+ Expires {format(accreditationExpiry, "MMM d, yyyy")} +

+ )} +
+ ) : ( +

+ {accreditationError + ? "Couldn't load your accreditation status." + : "Connect your wallet to see your accreditation status."} +

+ )} +
+

Risk profile diff --git a/components/dashboard/InvestorDashboard.tsx b/components/dashboard/InvestorDashboard.tsx index 8c73cf2..2bfbdf1 100644 --- a/components/dashboard/InvestorDashboard.tsx +++ b/components/dashboard/InvestorDashboard.tsx @@ -10,6 +10,7 @@ import { usePortfolioSummary, useRecentActivity, useUpcomingMaturities } from "@ import Link from "next/link"; import { cn } from "@/lib/utils"; import { Money, ExchangeRateDisclaimer } from "@/components/currency"; +import { AccreditationRenewalBanner } from "@/components/investor/AccreditationRenewalBanner"; export function InvestorDashboard() { const { data: portfolio, isLoading: portfolioLoading } = usePortfolioSummary(); @@ -28,6 +29,7 @@ export function InvestorDashboard() { return (
+

Dashboard

Welcome back! Here's an overview of your portfolio.

diff --git a/components/investor/AccreditationRenewalBanner.tsx b/components/investor/AccreditationRenewalBanner.tsx new file mode 100644 index 0000000..2f0526e --- /dev/null +++ b/components/investor/AccreditationRenewalBanner.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { useAccreditationStatus } from "@/hooks/useAccreditationStatus"; +import { + ACCREDITATION_RENEWAL_ROUTE, + getRenewalCountdown, + getRenewalSeverity, + type RenewalSeverity, +} from "@/lib/accreditationRenewal"; +import { cn } from "@/lib/utils"; + +const SEVERITY_STYLES: Record = { + notice: "border-yellow-300 bg-yellow-50 text-yellow-900 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-100", + warning: "border-amber-400 bg-amber-50 text-amber-900 dark:border-amber-600 dark:bg-amber-950/30 dark:text-amber-100", + critical: "border-red-400 bg-red-50 text-red-900 dark:border-red-700 dark:bg-red-950/30 dark:text-red-100", +}; + +/** Countdown precision is hours, so the clock only needs to tick once a minute. */ +const TICK_MS = 60_000; + +/** + * Renewal prompt for the connected investor's accreditation (#543). + * + * Shown on the dashboard and profile once the expiry is under 60 days away: + * yellow below 60 days, amber below 30, red below 7. Renewing moves the expiry + * out of range, so the banner disappears on the next status refresh. + */ +export function AccreditationRenewalBanner() { + const { data } = useAccreditationStatus(); + const expiresAt = data?.expires_at ?? null; + + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!expiresAt) return; + const id = setInterval(() => setNow(Date.now()), TICK_MS); + return () => clearInterval(id); + }, [expiresAt]); + + const severity = getRenewalSeverity(expiresAt, now); + if (!expiresAt || !severity) return null; + + const { days, hours, expired } = getRenewalCountdown(expiresAt, now); + const isCritical = severity === "critical"; + + return ( +
+
+

+ {expired ? "Your accreditation has expired" : "Your accreditation is expiring soon"} +

+

+ {expired ? ( + "Renew to keep investing in high-value invoices." + ) : ( + <> + Time left:{" "} + + {days}d {hours}h + + + )} +

+
+ +
+ ); +} diff --git a/components/investor/DisableRolloverDialog.tsx b/components/investor/DisableRolloverDialog.tsx new file mode 100644 index 0000000..d5ca190 --- /dev/null +++ b/components/investor/DisableRolloverDialog.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +interface DisableRolloverDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isPending?: boolean; +} + +/** + * Confirmation before `disable_rollover` is submitted (#545). Matured positions + * will no longer be reinvested until the investor turns rollover back on. + */ +export function DisableRolloverDialog({ + open, + onOpenChange, + onConfirm, + isPending = false, +}: DisableRolloverDialogProps) { + return ( + + + + Disable auto-rollover? + + Matured positions will stop being reinvested automatically. Your saved + criteria are kept, so you can re-enable rollover at any time. + + + + + + + + + ); +} diff --git a/components/investor/RolloverActivityList.tsx b/components/investor/RolloverActivityList.tsx new file mode 100644 index 0000000..0b35645 --- /dev/null +++ b/components/investor/RolloverActivityList.tsx @@ -0,0 +1,76 @@ +"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 { RolloverExecutedEvent } from "@/lib/api"; +import { sectorLabel } from "@/lib/autoInvest"; + +interface RolloverActivityListProps { + events: RolloverExecutedEvent[]; +} + +/** Recent `RolloverExecuted` events: matured positions reinvested automatically (#545). */ +export function RolloverActivityList({ events }: RolloverActivityListProps) { + return ( + + + + + Positions reinvested automatically at maturity + + + {events.length === 0 ? ( +

+ No rollovers yet. Your criteria apply when a position matures. +

+ ) : ( +
    + {events.map((event) => ( +
  • +
    +
    +
    + {event.yield_percentage}% + + {event.amount.toLocaleString()} XLM + +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/components/investor/RolloverSettingsPanel.tsx b/components/investor/RolloverSettingsPanel.tsx new file mode 100644 index 0000000..8114a75 --- /dev/null +++ b/components/investor/RolloverSettingsPanel.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Loader2, Pencil, RefreshCw, Save } 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 { AUTO_INVEST_SECTORS, AUTO_INVEST_SECTOR_LABELS } from "@/lib/autoInvest"; +import { useRollover } from "@/hooks/useRollover"; +import { + rolloverToDraft, + validateRolloverConfig, + type RolloverDraft, +} from "@/lib/rollover"; +import { RolloverActivityList } from "./RolloverActivityList"; +import { RolloverSummaryCard } from "./RolloverSummaryCard"; +import { DisableRolloverDialog } from "./DisableRolloverDialog"; + +interface RolloverSettingsPanelProps { + className?: string; +} + +/** + * Auto-rollover configuration panel (#545). + * + * Configure the sector, minimum yield, maximum invoice age and maximum amount + * that a matured position is reinvested into. Save submits + * `set_rollover_config`; the toggle disables with `disable_rollover` after a + * confirmation. `RolloverExecuted` events are listed underneath. + */ +export function RolloverSettingsPanel({ className }: RolloverSettingsPanelProps) { + const { config, events, isLoading, isError, isConnected, save, disable } = useRollover(); + + const [draft, setDraft] = useState(() => rolloverToDraft(null)); + const [formOpen, setFormOpen] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [confirmDisable, setConfirmDisable] = useState(false); + + const validation = validateRolloverConfig(draft); + const enabled = config?.isEnabled ?? false; + + // Mirror the saved config into the form while the user is not editing it. + useEffect(() => { + if (!formOpen) { + setDraft(rolloverToDraft(config)); + } + }, [config, formOpen]); + + const openEditor = () => { + setDraft(rolloverToDraft(config)); + setSubmitted(false); + setFormOpen(true); + }; + + const handleToggle = (next: boolean) => { + if (next) { + if (config) { + // Re-enable the saved config unchanged. + save.mutate({ + sector: config.sector, + minYield: config.minYield, + maxInvoiceAgeDays: config.maxInvoiceAgeDays, + maxAmount: config.maxAmount, + }); + } else { + openEditor(); + } + return; + } + setConfirmDisable(true); + }; + + const handleConfirmDisable = () => { + disable.mutate(undefined, { + onSettled: () => setConfirmDisable(false), + }); + }; + + const handleSave = () => { + setSubmitted(true); + if (!validation.valid || !validation.config) return; + save.mutate(validation.config, { + onSuccess: () => { + setFormOpen(false); + setSubmitted(false); + }, + }); + }; + + const updateField = (field: keyof RolloverDraft, value: string) => { + setDraft((current) => ({ ...current, [field]: value })); + }; + + if (!isConnected) { + return ( + + + + + + Connect your wallet to configure automatic rollover at maturity. + + + + ); + } + + return ( + + +
+
+ + + + Reinvest matured positions into invoices that match your criteria. + +
+
+ + +
+
+
+ + + {isLoading ? ( +
+ + + +
+ ) : isError ? ( +

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

+ ) : ( + <> + {config && !formOpen && ( +
+ + +
+ )} + + {(!config || formOpen) && ( +
{ + event.preventDefault(); + handleSave(); + }} + > +
+ + + {submitted && validation.errors.sector && ( +

+ {validation.errors.sector} +

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

+ {validation.errors.minYield} +

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

+ {validation.errors.maxInvoiceAgeDays} +

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

+ {validation.errors.maxAmount} +

+ )} +
+
+ +
+ + {config && ( + + )} +
+
+ )} + + + + )} +
+ + +
+ ); +} diff --git a/components/investor/RolloverSummaryCard.tsx b/components/investor/RolloverSummaryCard.tsx new file mode 100644 index 0000000..d1ee974 --- /dev/null +++ b/components/investor/RolloverSummaryCard.tsx @@ -0,0 +1,88 @@ +"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 { AUTO_INVEST_SECTOR_LABELS, isAutoInvestSector } from "@/lib/autoInvest"; +import type { RolloverConfig } from "@/lib/rollover"; + +interface RolloverSummaryCardProps { + config: RolloverConfig; +} + +/** Read-only summary of the active rollover criteria (#545). */ +export function RolloverSummaryCard({ config }: RolloverSummaryCardProps) { + const sectorLabel = isAutoInvestSector(config.sector) + ? AUTO_INVEST_SECTOR_LABELS[config.sector] + : config.sector; + + return ( + + +
+ Active rollover + + {config.isEnabled ? "Enabled" : "Disabled"} + +
+ + {config.isEnabled + ? config.updatedAt + ? `Last updated ${format(new Date(config.updatedAt), "MMM d, yyyy HH:mm")}` + : "Rollover is active with these criteria" + : "Saved, but auto-rollover is currently off"} + +
+ +
+
+
+
+
+ {sectorLabel} +
+
+
+
+
+
+ {config.minYield}% +
+
+
+
+
+
+ {config.maxInvoiceAgeDays} day{config.maxInvoiceAgeDays !== 1 ? "s" : ""} +
+
+
+
+
+
+ {config.maxAmount.toLocaleString()} XLM +
+
+
+
+
+ ); +} diff --git a/components/investor/__tests__/AccreditationRenewalBanner.test.tsx b/components/investor/__tests__/AccreditationRenewalBanner.test.tsx new file mode 100644 index 0000000..f0316c1 --- /dev/null +++ b/components/investor/__tests__/AccreditationRenewalBanner.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { useAccreditationStatus } from "@/hooks/useAccreditationStatus"; +import { AccreditationRenewalBanner } from "../AccreditationRenewalBanner"; + +vi.mock("@/hooks/useAccreditationStatus", () => ({ + useAccreditationStatus: vi.fn(), +})); + +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.parse("2026-10-01T00:00:00Z"); + +function withExpiry(expiresAt: string | null) { + vi.mocked(useAccreditationStatus).mockReturnValue({ + data: { wallet: "GINV", tier: "accredited", high_value_threshold: 100_000, expires_at: expiresAt }, + } as unknown as ReturnType); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("AccreditationRenewalBanner (#543)", () => { + it("renders nothing when the expiry is more than 60 days away", () => { + withExpiry(new Date(NOW + 90 * DAY).toISOString()); + render(); + expect(screen.queryByTestId("accreditation-renewal-banner")).not.toBeInTheDocument(); + }); + + it("shows the countdown and a renew CTA inside the 60-day window", () => { + withExpiry(new Date(NOW + 20 * DAY + 4 * 60 * 60 * 1000).toISOString()); + render(); + + const banner = screen.getByTestId("accreditation-renewal-banner"); + expect(banner).toHaveAttribute("data-severity", "warning"); + expect(screen.getByTestId("accreditation-renewal-countdown")).toHaveTextContent("20d 4h"); + expect(screen.getByRole("link", { name: "Renew now" })).toHaveAttribute( + "href", + "/profile#accreditation", + ); + }); + + it("escalates to the critical style within 7 days", () => { + withExpiry(new Date(NOW + 2 * DAY).toISOString()); + render(); + expect(screen.getByTestId("accreditation-renewal-banner")).toHaveAttribute( + "data-severity", + "critical", + ); + }); + + it("renders nothing when the backend has not reported an expiry", () => { + withExpiry(null); + render(); + expect(screen.queryByTestId("accreditation-renewal-banner")).not.toBeInTheDocument(); + }); +}); diff --git a/components/investor/__tests__/RolloverSettingsPanel.test.tsx b/components/investor/__tests__/RolloverSettingsPanel.test.tsx new file mode 100644 index 0000000..5c2671d --- /dev/null +++ b/components/investor/__tests__/RolloverSettingsPanel.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRollover } from "@/hooks/useRollover"; +import type { RolloverConfig } from "@/lib/rollover"; +import { RolloverSettingsPanel } from "../RolloverSettingsPanel"; + +vi.mock("@/hooks/useRollover", () => ({ + useRollover: vi.fn(), +})); + +const mockedUseRollover = vi.mocked(useRollover); + +type HookReturn = ReturnType; + +function fakeMutation() { + return { mutate: vi.fn(), isPending: false } as unknown as T; +} + +function setup(overrides: Partial = {}) { + const save = fakeMutation(); + const disable = fakeMutation(); + mockedUseRollover.mockReturnValue({ + config: null, + events: [], + isLoading: false, + isError: false, + isConnected: true, + save, + disable, + ...overrides, + }); + return { save, disable }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("RolloverSettingsPanel (#545)", () => { + it("asks for a wallet when disconnected", () => { + setup({ isConnected: false }); + render(); + expect(screen.getByTestId("rollover-connect")).toBeInTheDocument(); + }); + + it("blocks saving until a sector is chosen", async () => { + const { save } = setup(); + render(); + + await userEvent.click(screen.getByTestId("rollover-save")); + + expect(screen.getByTestId("rollover-sector-error")).toBeInTheDocument(); + expect(save.mutate).not.toHaveBeenCalled(); + }); + + it("saves the chosen criteria as one config", async () => { + const { save } = setup(); + render(); + + await userEvent.selectOptions(screen.getByTestId("rollover-sector"), "energy"); + await userEvent.click(screen.getByTestId("rollover-save")); + + expect(save.mutate).toHaveBeenCalledWith( + { sector: "energy", minYield: 5, maxInvoiceAgeDays: 30, maxAmount: 1000 }, + expect.any(Object), + ); + }); + + it("summarises the active rollover and confirms before disabling", async () => { + const config: RolloverConfig = { + sector: "energy", + minYield: 6, + maxInvoiceAgeDays: 60, + maxAmount: 2000, + isEnabled: true, + }; + const { disable } = setup({ config }); + render(); + + expect(screen.getByTestId("rollover-summary-sector")).toHaveTextContent("Energy"); + expect(screen.getByTestId("rollover-summary-min-yield")).toHaveTextContent("6%"); + + await userEvent.click(screen.getByTestId("rollover-toggle")); + await userEvent.click(await screen.findByTestId("confirm-disable-rollover")); + + expect(disable.mutate).toHaveBeenCalled(); + }); +}); diff --git a/components/pools/PoolDetail.tsx b/components/pools/PoolDetail.tsx index 1a3212c..a2211ca 100644 --- a/components/pools/PoolDetail.tsx +++ b/components/pools/PoolDetail.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, TableBody, @@ -15,6 +16,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { PoolSubscriptionPanel } from "@/components/pools/PoolSubscriptionPanel"; import { useInvestPoolMutation, useInvoicePool } from "@/hooks/useInvoicePools"; import { useWallet } from "@/context/WalletContext"; import { usePageTitle } from "@/hooks/usePageTitle"; @@ -42,56 +44,73 @@ function PoolInvestPanel({ pool }: { pool: InvoicePool }) {

Invest in this pool

-
-
- - setAmount(e.target.value)} - data-testid="pool-amount-input" - /> -
- - {valid && ( -
-

Allocation preview

-
    - {allocations.map((allocation) => ( -
  • - {allocation.name} - - {allocation.amount.toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}{" "} - XLM - -
  • - ))} -
-
- )} - - {!isConnected && ( -

Connect a wallet to invest.

- )} - - -
+ + + + Invest once + + + Subscribe + + + + +
+
+ + setAmount(e.target.value)} + data-testid="pool-amount-input" + /> +
+ + {valid && ( +
+

Allocation preview

+
    + {allocations.map((allocation) => ( +
  • + {allocation.name} + + {allocation.amount.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + XLM + +
  • + ))} +
+
+ )} + + {!isConnected && ( +

Connect a wallet to invest.

+ )} + + +
+
+ + + + +
); diff --git a/components/pools/PoolSubscriptionPanel.tsx b/components/pools/PoolSubscriptionPanel.tsx new file mode 100644 index 0000000..b804e9e --- /dev/null +++ b/components/pools/PoolSubscriptionPanel.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState } from "react"; +import { format } from "date-fns"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { usePoolSubscription } from "@/hooks/usePoolSubscription"; +import type { PoolSubscription, PoolSubscriptionInterval } from "@/lib/api"; +import { + POOL_SUBSCRIPTION_INTERVALS, + POOL_SUBSCRIPTION_INTERVAL_LABELS, + nextExecutionDate, + projectedAnnualInvestment, + validateSubscriptionAmount, +} from "@/lib/poolSubscriptions"; +import { cn } from "@/lib/utils"; + +function formatXlm(value: number): string { + return `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })} XLM`; +} + +function formatDateOrDash(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : format(date, "MMM d, yyyy"); +} + +function ActiveSubscriptionCard({ + subscription, + onCancel, + isCancelling, +}: { + subscription: PoolSubscription; + onCancel: () => void; + isCancelling: boolean; +}) { + return ( + + +

Active subscription

+
+ +
+
+
Investing
+
+ {formatXlm(subscription.amount)} · {POOL_SUBSCRIPTION_INTERVAL_LABELS[subscription.interval]} +
+
+
+
Next execution
+
+ {formatDateOrDash(subscription.next_execution_at)} +
+
+
+
Total invested to date
+
+ {formatXlm(subscription.total_invested)} +
+
+
+ +
+
+ ); +} + +/** + * Subscribe tab of the pool invest panel (#544). Sets up a recurring investment + * with `create_subscription`, or shows the active subscription with a + * `cancel_subscription` action. + */ +export function PoolSubscriptionPanel({ poolId }: { poolId: string }) { + const { subscription, isLoading, isError, isConnected, create, cancel } = + usePoolSubscription(poolId); + const [amount, setAmount] = useState(""); + const [cadence, setCadence] = useState("monthly"); + const [submitted, setSubmitted] = useState(false); + + if (!isConnected) { + return

Connect a wallet to subscribe.

; + } + + if (isLoading) { + return ; + } + + if (isError) { + return ( +

+ Couldn't load your subscription. +

+ ); + } + + if (subscription?.is_active) { + return ( + cancel.mutate()} + isCancelling={cancel.isPending} + /> + ); + } + + const validation = validateSubscriptionAmount(amount); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setSubmitted(true); + if (!validation.valid || validation.amount === null) return; + create.mutate( + { amount: validation.amount, interval: cadence }, + { + onSuccess: () => { + setAmount(""); + setSubmitted(false); + }, + }, + ); + } + + const annual = validation.valid && validation.amount !== null + ? projectedAnnualInvestment(validation.amount, cadence) + : null; + + return ( +
+
+ + setAmount(event.target.value)} + data-testid="pool-subscription-amount-input" + /> + {submitted && validation.error && ( +

+ {validation.error} +

+ )} +
+ +
+ Interval +
+ {POOL_SUBSCRIPTION_INTERVALS.map((option) => { + const selected = cadence === option; + return ( + + ); + })} +
+
+ + {validation.valid && annual !== null && ( +
+

+ Next execution:{" "} + + {format(nextExecutionDate(cadence, new Date()), "MMM d, yyyy")} + +

+

+ Projected annual investment:{" "} + + {formatXlm(annual)} + +

+
+ )} + + +
+ ); +} diff --git a/components/pools/__tests__/PoolDetail.test.tsx b/components/pools/__tests__/PoolDetail.test.tsx index 354d0a7..b4b51f1 100644 --- a/components/pools/__tests__/PoolDetail.test.tsx +++ b/components/pools/__tests__/PoolDetail.test.tsx @@ -1,16 +1,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { PoolDetail } from "../PoolDetail"; import type { InvoicePool } from "@/lib/api"; const mockPool = vi.fn(); const mockInvest = vi.fn(); +const mockSubscription = vi.fn(); +const mockCreateSubscription = vi.fn(); +const mockCancelSubscription = vi.fn(); vi.mock("@/hooks/useInvoicePools", () => ({ useInvoicePool: () => mockPool(), useInvestPoolMutation: () => ({ mutate: mockInvest, isPending: false }), })); +vi.mock("@/hooks/usePoolSubscription", () => ({ + usePoolSubscription: () => mockSubscription(), +})); + vi.mock("@/context/WalletContext", () => ({ useWallet: () => ({ address: "GINV", isConnected: true }), })); @@ -32,6 +40,14 @@ describe("PoolDetail", () => { beforeEach(() => { vi.clearAllMocks(); mockPool.mockReturnValue({ data: pool, isLoading: false, isError: false }); + mockSubscription.mockReturnValue({ + subscription: null, + isLoading: false, + isError: false, + isConnected: true, + create: { mutate: mockCreateSubscription, isPending: false }, + cancel: { mutate: mockCancelSubscription, isPending: false }, + }); }); it("renders each invoice with sector, weight and yield", () => { @@ -71,4 +87,45 @@ describe("PoolDetail", () => { render(); expect(screen.getByRole("alert")).toHaveTextContent("Pool not found"); }); + + it("sets up a recurring subscription from the Subscribe tab (#544)", async () => { + render(); + await userEvent.click(screen.getByTestId("pool-subscribe-tab")); + + await userEvent.click(screen.getByTestId("pool-subscription-interval-weekly")); + await userEvent.type(screen.getByTestId("pool-subscription-amount-input"), "100"); + expect(screen.getByTestId("pool-subscription-preview-annual")).toHaveTextContent("5,200 XLM"); + + await userEvent.click(screen.getByTestId("pool-subscription-submit")); + expect(mockCreateSubscription).toHaveBeenCalledWith( + { amount: 100, interval: "weekly" }, + expect.any(Object), + ); + }); + + it("shows the active subscription with a cancel action (#544)", async () => { + mockSubscription.mockReturnValue({ + subscription: { + id: "sub-1", + pool_id: "pool-1", + amount: 50, + interval: "monthly", + next_execution_at: "2026-11-01T00:00:00Z", + total_invested: 150, + is_active: true, + }, + isLoading: false, + isError: false, + isConnected: true, + create: { mutate: mockCreateSubscription, isPending: false }, + cancel: { mutate: mockCancelSubscription, isPending: false }, + }); + render(); + await userEvent.click(screen.getByTestId("pool-subscribe-tab")); + + expect(screen.getByTestId("pool-subscription-config")).toHaveTextContent("50 XLM · Monthly"); + expect(screen.getByTestId("pool-subscription-total")).toHaveTextContent("150 XLM"); + await userEvent.click(screen.getByTestId("pool-subscription-cancel")); + expect(mockCancelSubscription).toHaveBeenCalled(); + }); }); diff --git a/components/settings/AlertPreferencesSettings.tsx b/components/settings/AlertPreferencesSettings.tsx index 8009ac4..25bb312 100644 --- a/components/settings/AlertPreferencesSettings.tsx +++ b/components/settings/AlertPreferencesSettings.tsx @@ -7,19 +7,34 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; +import { MaturityReminderSettings } from "@/components/settings/MaturityReminderSettings"; import { useAlertPreferences } from "@/hooks/useAlertPreferences"; -import { ALERT_EVENTS, DEFAULT_ALERT_PREFERENCES } from "@/lib/alertPreferences"; +import { + ALERT_EVENTS, + DEFAULT_ALERT_PREFERENCES, + type MaturityReminderInterval, +} from "@/lib/alertPreferences"; import type { AlertPreferences } from "@/lib/api"; -/** Per-event alert toggles with a single save that submits `set_alert_preferences` (#531). */ +/** + * Per-event alert toggles and maturity reminder intervals (#542) with a single + * save that submits `set_alert_preferences` (#531). + */ export function AlertPreferencesSettings() { - const { preferences, isLoading, isError, isConnected, save } = useAlertPreferences(); + const { preferences, maturityReminderDays, isLoading, isError, isConnected, save } = + useAlertPreferences(); const [draft, setDraft] = useState(DEFAULT_ALERT_PREFERENCES); + const [draftReminderDays, setDraftReminderDays] = + useState(maturityReminderDays); useEffect(() => { setDraft(preferences); }, [preferences]); + useEffect(() => { + setDraftReminderDays(maturityReminderDays); + }, [maturityReminderDays]); + if (!isConnected) { return ( @@ -53,7 +68,7 @@ export function AlertPreferencesSettings() { className="space-y-6" onSubmit={(event) => { event.preventDefault(); - save.mutate(draft); + save.mutate({ ...draft, maturity_reminder_days: draftReminderDays }); }} >
@@ -76,6 +91,7 @@ export function AlertPreferencesSettings() {
))}
+