From fa8dfd0393e4d16e2489251acf4351eb11a454a4 Mon Sep 17 00:00:00 2001 From: andrepaul1099-stack Date: Thu, 22 Oct 2026 00:29:02 +0100 Subject: [PATCH 1/4] feat(settings): add maturity reminder intervals to alert preferences (#542) Adds a Maturity Reminders section under alert preferences with toggles for 30, 14, 7 and 1 days before maturity. Saving submits the reminder days with the existing alert toggles, and a preview lists upcoming maturities from the investor's holdings sorted by nearest date, with the reminders still ahead. --- .../settings/AlertPreferencesSettings.tsx | 24 +++- .../settings/MaturityReminderSettings.tsx | 109 ++++++++++++++++++ .../AlertPreferencesSettings.test.tsx | 19 +++ hooks/useAlertPreferences.ts | 18 ++- lib/__tests__/alertPreferences.test.ts | 42 ++++++- lib/alertPreferences.ts | 82 ++++++++++++- lib/api/index.ts | 10 +- 7 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 components/settings/MaturityReminderSettings.tsx 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() {
))} + + + ); +} 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/lib/__tests__/accreditationRenewal.test.ts b/lib/__tests__/accreditationRenewal.test.ts new file mode 100644 index 0000000..9a25073 --- /dev/null +++ b/lib/__tests__/accreditationRenewal.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { getRenewalCountdown, getRenewalSeverity } from "../accreditationRenewal"; + +const DAY = 24 * 60 * 60 * 1000; +const now = Date.parse("2026-10-01T00:00:00Z"); +const expiringIn = (days: number) => new Date(now + days * DAY).toISOString(); + +describe("accreditation renewal severity (#543)", () => { + it("stays hidden while more than 60 days remain", () => { + expect(getRenewalSeverity(expiringIn(61), now)).toBeNull(); + expect(getRenewalSeverity(expiringIn(60), now)).toBeNull(); + }); + + it("escalates yellow -> amber -> red at 60, 30 and 7 days", () => { + expect(getRenewalSeverity(expiringIn(59), now)).toBe("notice"); + expect(getRenewalSeverity(expiringIn(29), now)).toBe("warning"); + expect(getRenewalSeverity(expiringIn(6), now)).toBe("critical"); + }); + + it("treats an expired accreditation as critical", () => { + expect(getRenewalSeverity(expiringIn(-1), now)).toBe("critical"); + }); + + it("hides the prompt when there is no usable expiry", () => { + expect(getRenewalSeverity(null, now)).toBeNull(); + expect(getRenewalSeverity(undefined, now)).toBeNull(); + expect(getRenewalSeverity("not-a-date", now)).toBeNull(); + }); +}); + +describe("accreditation renewal countdown (#543)", () => { + it("reports whole days and hours until expiry", () => { + const expiry = new Date(now + 3 * DAY + 5 * 60 * 60 * 1000).toISOString(); + expect(getRenewalCountdown(expiry, now)).toEqual({ days: 3, hours: 5, expired: false }); + }); + + it("clamps to zero once expired", () => { + expect(getRenewalCountdown(expiringIn(-2), now)).toEqual({ days: 0, hours: 0, expired: true }); + }); +}); diff --git a/lib/accreditationRenewal.ts b/lib/accreditationRenewal.ts new file mode 100644 index 0000000..b7801a1 --- /dev/null +++ b/lib/accreditationRenewal.ts @@ -0,0 +1,66 @@ +/** + * Accreditation renewal reminders (#543). + * + * The renewal prompt appears once the accreditation expiry is under 60 days + * away, and escalates as the date gets closer. The thresholds live here so the + * banner and its tests share one definition. + */ + +const MS_PER_HOUR = 60 * 60 * 1000; +const MS_PER_DAY = 24 * MS_PER_HOUR; + +/** Where the "Renew now" CTA sends the investor. */ +export const ACCREDITATION_RENEWAL_ROUTE = "/profile#accreditation"; + +/** Days before expiry at which each severity starts. */ +export const RENEWAL_THRESHOLD_DAYS = { + notice: 60, + warning: 30, + critical: 7, +} as const; + +export type RenewalSeverity = "notice" | "warning" | "critical"; + +/** + * Severity for an expiry date, or `null` when the prompt should stay hidden. + * An expiry that has already passed is treated as critical. + */ +export function getRenewalSeverity( + expiresAt: string | null | undefined, + now: number = Date.now(), +): RenewalSeverity | null { + const remaining = remainingMs(expiresAt, now); + if (remaining === null) return null; + if (remaining < RENEWAL_THRESHOLD_DAYS.critical * MS_PER_DAY) return "critical"; + if (remaining < RENEWAL_THRESHOLD_DAYS.warning * MS_PER_DAY) return "warning"; + if (remaining < RENEWAL_THRESHOLD_DAYS.notice * MS_PER_DAY) return "notice"; + return null; +} + +export interface RenewalCountdown { + days: number; + hours: number; + expired: boolean; +} + +/** Whole days and hours left until expiry, clamped at zero. */ +export function getRenewalCountdown( + expiresAt: string, + now: number = Date.now(), +): RenewalCountdown { + const remaining = remainingMs(expiresAt, now) ?? 0; + if (remaining <= 0) return { days: 0, hours: 0, expired: true }; + return { + days: Math.floor(remaining / MS_PER_DAY), + hours: Math.floor((remaining % MS_PER_DAY) / MS_PER_HOUR), + expired: false, + }; +} + +/** Milliseconds until expiry, or `null` when there is no valid date. */ +function remainingMs(expiresAt: string | null | undefined, now: number): number | null { + if (!expiresAt) return null; + const expiry = new Date(expiresAt).getTime(); + if (Number.isNaN(expiry)) return null; + return expiry - now; +} diff --git a/lib/api/index.ts b/lib/api/index.ts index 69d58bc..88cfada 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -2968,6 +2968,8 @@ export interface AccreditationStatus { tier: AccreditationTier; /** Minimum face value (XLM) that requires accreditation. */ high_value_threshold: number; + /** When the current accreditation lapses; absent before the backend reports it (#543). */ + expires_at?: string | null; } export type AccreditationTier = "unaccredited" | "accredited" | "qualified"; @@ -4033,6 +4035,7 @@ function normalizeAccreditationStatus(raw: any, wallet: string): AccreditationSt high_value_threshold: Number( raw.high_value_threshold ?? raw.highValueThreshold ?? raw.threshold ?? 100_000 ), + expires_at: raw.expires_at ?? raw.expiresAt ?? null, }; } From 67f4ecc21e5401b7004de6f2d05706afd3fdbc49 Mon Sep 17 00:00:00 2001 From: andrepaul1099-stack Date: Thu, 22 Oct 2026 00:33:51 +0100 Subject: [PATCH 3/4] feat(pools): add recurring subscription investment to pool invest panel (#544) Adds Invest once and Subscribe tabs to the pool invest panel. The Subscribe tab sets up a daily, weekly or monthly investment (create_subscription), shows the next execution date and projected annual investment as a preview, and lists the active subscription with total invested to date and a cancel action (cancel_subscription). --- components/pools/PoolDetail.tsx | 119 +++++----- components/pools/PoolSubscriptionPanel.tsx | 205 ++++++++++++++++++ .../pools/__tests__/PoolDetail.test.tsx | 57 +++++ hooks/usePoolSubscription.ts | 73 +++++++ lib/__tests__/poolSubscriptions.test.ts | 30 +++ lib/api/index.ts | 65 ++++++ lib/poolSubscriptions.ts | 62 ++++++ 7 files changed, 561 insertions(+), 50 deletions(-) create mode 100644 components/pools/PoolSubscriptionPanel.tsx create mode 100644 hooks/usePoolSubscription.ts create mode 100644 lib/__tests__/poolSubscriptions.test.ts create mode 100644 lib/poolSubscriptions.ts 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/hooks/usePoolSubscription.ts b/hooks/usePoolSubscription.ts new file mode 100644 index 0000000..6736dd4 --- /dev/null +++ b/hooks/usePoolSubscription.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + cancelPoolSubscription, + createPoolSubscription, + fetchPoolSubscription, + type CreatePoolSubscriptionInput, +} from "@/lib/api"; +import { useWallet } from "@/context/WalletContext"; + +export function poolSubscriptionQueryKey(poolId: string, address: string | null) { + return ["pool-subscription", poolId, address] as const; +} + +/** + * The connected wallet's subscription to one pool (#544). Creating and + * cancelling submit `create_subscription` and `cancel_subscription`. + */ +export function usePoolSubscription(poolId: string) { + const { address, isConnected } = useWallet(); + const queryClient = useQueryClient(); + const key = poolSubscriptionQueryKey(poolId, address); + + const query = useQuery({ + queryKey: key, + queryFn: () => fetchPoolSubscription(poolId, address as string), + enabled: isConnected && Boolean(address) && Boolean(poolId), + staleTime: 30 * 1000, + }); + + const create = useMutation({ + mutationFn: (input: CreatePoolSubscriptionInput) => { + if (!address) throw new Error("Connect a wallet to subscribe"); + return createPoolSubscription(poolId, input, address); + }, + onSuccess: () => { + toast.success("Subscription started"); + }, + onError: () => { + toast.error("Couldn't start your subscription. Please try again."); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: key }); + }, + }); + + const cancel = useMutation({ + mutationFn: () => { + if (!address) throw new Error("Connect a wallet to cancel"); + return cancelPoolSubscription(poolId, address); + }, + onSuccess: () => { + toast.success("Subscription cancelled"); + }, + onError: () => { + toast.error("Couldn't cancel your subscription. Please try again."); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: key }); + }, + }); + + return { + subscription: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + isConnected, + create, + cancel, + }; +} diff --git a/lib/__tests__/poolSubscriptions.test.ts b/lib/__tests__/poolSubscriptions.test.ts new file mode 100644 index 0000000..957a60a --- /dev/null +++ b/lib/__tests__/poolSubscriptions.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { + nextExecutionDate, + projectedAnnualInvestment, + validateSubscriptionAmount, +} from "../poolSubscriptions"; + +// Local-time constructor so the day arithmetic does not depend on the runner's timezone. +const from = new Date(2026, 9, 1); + +describe("pool subscriptions (#544)", () => { + it("advances the next execution by one interval", () => { + expect(nextExecutionDate("daily", from)).toEqual(new Date(2026, 9, 2)); + expect(nextExecutionDate("weekly", from)).toEqual(new Date(2026, 9, 8)); + expect(nextExecutionDate("monthly", from)).toEqual(new Date(2026, 10, 1)); + }); + + it("projects a full year of executions at the chosen cadence", () => { + expect(projectedAnnualInvestment(100, "daily")).toBe(36_500); + expect(projectedAnnualInvestment(100, "weekly")).toBe(5_200); + expect(projectedAnnualInvestment(100, "monthly")).toBe(1_200); + }); + + it("requires a positive amount per execution", () => { + expect(validateSubscriptionAmount("").valid).toBe(false); + expect(validateSubscriptionAmount("0").valid).toBe(false); + expect(validateSubscriptionAmount("-5").valid).toBe(false); + expect(validateSubscriptionAmount("12.5")).toEqual({ valid: true, amount: 12.5 }); + }); +}); diff --git a/lib/api/index.ts b/lib/api/index.ts index 88cfada..f853e0d 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -2867,6 +2867,71 @@ export async function investInPool( return res.json(); } +/* ─── Pool subscriptions (#544) ──────────────────────────────────────────── + * A subscription invests a fixed amount into a pool on a schedule. Creating + * one submits `create_subscription`; cancelling submits `cancel_subscription`. + */ + +export type PoolSubscriptionInterval = "daily" | "weekly" | "monthly"; + +export interface PoolSubscription { + id: string; + pool_id: string; + /** Amount invested on each execution, in XLM. */ + amount: number; + interval: PoolSubscriptionInterval; + next_execution_at: string; + /** Sum of every execution so far, in XLM. */ + total_invested: number; + is_active: boolean; +} + +/** Resolves `null` when the wallet has no subscription to this pool (404). */ +export async function fetchPoolSubscription( + poolId: string, + wallet: string, +): Promise { + const params = new URLSearchParams({ wallet }); + const res = await fetch( + `${API_BASE}/pools/${encodeURIComponent(poolId)}/subscription?${params.toString()}`, + ); + if (res.status === 404) return null; + if (!res.ok) throw new Error("Failed to fetch pool subscription"); + return res.json(); +} + +export interface CreatePoolSubscriptionInput { + amount: number; + interval: PoolSubscriptionInterval; +} + +export async function createPoolSubscription( + poolId: string, + input: CreatePoolSubscriptionInput, + wallet: string, +): Promise<{ success: boolean; tx_hash?: string }> { + const res = await fetch(`${API_BASE}/pools/${encodeURIComponent(poolId)}/subscription`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "create_subscription", wallet, ...input }), + }); + if (!res.ok) throw new Error("Failed to start pool subscription"); + return res.json(); +} + +export async function cancelPoolSubscription( + poolId: string, + wallet: string, +): Promise<{ success: boolean; tx_hash?: string }> { + const res = await fetch(`${API_BASE}/pools/${encodeURIComponent(poolId)}/subscription`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "cancel_subscription", wallet }), + }); + if (!res.ok) throw new Error("Failed to cancel pool subscription"); + return res.json(); +} + // --------------------------------------------------------------------------- // Investor loyalty (#509) // --------------------------------------------------------------------------- diff --git a/lib/poolSubscriptions.ts b/lib/poolSubscriptions.ts new file mode 100644 index 0000000..91e992e --- /dev/null +++ b/lib/poolSubscriptions.ts @@ -0,0 +1,62 @@ +import { addDays, addMonths, addWeeks } from "date-fns"; +import type { PoolSubscriptionInterval } from "@/lib/api"; + +/** + * Pool subscription schedule helpers (#544). + * + * The preview on the Subscribe tab and the active subscription card both use + * these, so the next-execution date and annual projection have one definition. + */ + +export const POOL_SUBSCRIPTION_INTERVALS: readonly PoolSubscriptionInterval[] = [ + "daily", + "weekly", + "monthly", +]; + +export const POOL_SUBSCRIPTION_INTERVAL_LABELS: Record = { + daily: "Daily", + weekly: "Weekly", + monthly: "Monthly", +}; + +const EXECUTIONS_PER_YEAR: Record = { + daily: 365, + weekly: 52, + monthly: 12, +}; + +/** The first execution after `from`, one interval later. */ +export function nextExecutionDate(interval: PoolSubscriptionInterval, from: Date): Date { + switch (interval) { + case "daily": + return addDays(from, 1); + case "weekly": + return addWeeks(from, 1); + case "monthly": + return addMonths(from, 1); + } +} + +/** Amount invested over a full year at this cadence, in XLM. */ +export function projectedAnnualInvestment( + amount: number, + interval: PoolSubscriptionInterval, +): number { + return amount * EXECUTIONS_PER_YEAR[interval]; +} + +export interface SubscriptionAmountResult { + valid: boolean; + error?: string; + amount: number | null; +} + +/** Validates the per-execution amount typed into the form. */ +export function validateSubscriptionAmount(raw: string): SubscriptionAmountResult { + const amount = raw.trim() === "" ? Number.NaN : Number(raw); + if (!Number.isFinite(amount) || amount <= 0) { + return { valid: false, error: "Enter an amount greater than 0.", amount: null }; + } + return { valid: true, amount }; +} From 7da16058c792022ce0f73d3515c701f77fd65738 Mon Sep 17 00:00:00 2001 From: andrepaul1099-stack Date: Thu, 22 Oct 2026 00:36:47 +0100 Subject: [PATCH 4/4] feat(investor): add invoice auto-rollover configuration in settings (#545) Adds an Auto-Rollover panel to investor settings. Investors pick a sector and set a minimum yield, maximum invoice age and maximum amount for reinvesting a matured position. Save submits set_rollover_config, and the form blocks saving until the required fields are valid. The enable toggle asks for confirmation before disable_rollover. An active criteria card and the RolloverExecuted activity feed sit below the form. --- app/(dashboard)/settings/page.tsx | 6 + components/investor/DisableRolloverDialog.tsx | 56 ++++ components/investor/RolloverActivityList.tsx | 76 +++++ components/investor/RolloverSettingsPanel.tsx | 302 ++++++++++++++++++ components/investor/RolloverSummaryCard.tsx | 88 +++++ .../__tests__/RolloverSettingsPanel.test.tsx | 89 ++++++ hooks/useRollover.ts | 107 +++++++ lib/__tests__/rollover.test.ts | 105 ++++++ lib/api/index.ts | 86 +++++ lib/rollover.ts | 144 +++++++++ 10 files changed, 1059 insertions(+) create mode 100644 components/investor/DisableRolloverDialog.tsx create mode 100644 components/investor/RolloverActivityList.tsx create mode 100644 components/investor/RolloverSettingsPanel.tsx create mode 100644 components/investor/RolloverSummaryCard.tsx create mode 100644 components/investor/__tests__/RolloverSettingsPanel.test.tsx create mode 100644 hooks/useRollover.ts create mode 100644 lib/__tests__/rollover.test.ts create mode 100644 lib/rollover.ts 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

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__/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/hooks/useRollover.ts b/hooks/useRollover.ts new file mode 100644 index 0000000..d7f73e4 --- /dev/null +++ b/hooks/useRollover.ts @@ -0,0 +1,107 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { toast } from "sonner"; +import { + disableRollover, + fetchRolloverConfig, + fetchRolloverExecutedEvents, + setRolloverConfig, + type RolloverExecutedEvent, +} from "@/lib/api"; +import { useWallet } from "@/context/WalletContext"; +import { + toRolloverConfig, + toRolloverConfigPayload, + type RolloverConfig, + type RolloverConfigInput, +} from "@/lib/rollover"; + +/** + * Auto-rollover settings state (#545). + * + * Reads the saved config and the `RolloverExecuted` event log for the connected + * wallet, and exposes the two contract writes: `set_rollover_config` on save + * and `disable_rollover` behind a confirmation in the UI. + */ + +export function rolloverConfigQueryKey(address: string | null) { + return ["rollover", "config", address] as const; +} + +export function rolloverEventsQueryKey(address: string | null) { + return ["rollover", "events", address] as const; +} + +export function useRollover() { + const { address, isConnected } = useWallet(); + const queryClient = useQueryClient(); + const configKey = rolloverConfigQueryKey(address); + + const configQuery = useQuery({ + queryKey: configKey, + queryFn: () => fetchRolloverConfig(address as string), + enabled: isConnected && Boolean(address), + staleTime: 30 * 1000, + }); + + const eventsQuery = useQuery({ + queryKey: rolloverEventsQueryKey(address), + queryFn: () => fetchRolloverExecutedEvents(address as string), + enabled: isConnected && Boolean(address), + staleTime: 30 * 1000, + }); + + const config: RolloverConfig | null = useMemo( + () => (configQuery.data?.config ? toRolloverConfig(configQuery.data.config) : null), + [configQuery.data], + ); + + const events: RolloverExecutedEvent[] = useMemo( + () => eventsQuery.data ?? [], + [eventsQuery.data], + ); + + const save = useMutation({ + mutationFn: (input: RolloverConfigInput) => { + if (!address) throw new Error("Connect a wallet to configure rollover"); + return setRolloverConfig(address, toRolloverConfigPayload(input, true)); + }, + onSuccess: () => { + toast.success("Rollover settings saved"); + }, + onError: () => { + toast.error("Couldn't save your rollover settings. Please try again."); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: configKey }); + }, + }); + + const disable = useMutation({ + mutationFn: () => { + if (!address) throw new Error("Connect a wallet to configure rollover"); + return disableRollover(address); + }, + onSuccess: () => { + toast.success("Auto-rollover disabled"); + }, + onError: () => { + toast.error("Couldn't disable auto-rollover. Please try again."); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: configKey }); + }, + }); + + return { + config, + events, + isLoading: configQuery.isLoading || eventsQuery.isLoading, + isError: configQuery.isError, + isConnected, + save, + disable, + }; +} diff --git a/lib/__tests__/rollover.test.ts b/lib/__tests__/rollover.test.ts new file mode 100644 index 0000000..09cfdf6 --- /dev/null +++ b/lib/__tests__/rollover.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { + rolloverToDraft, + toRolloverConfig, + toRolloverConfigPayload, + validateRolloverConfig, + type RolloverDraft, +} from "../rollover"; + +const validDraft: RolloverDraft = { + sector: "energy", + minYield: "6", + maxInvoiceAgeDays: "60", + maxAmount: "2000", +}; + +describe("rollover validation (#545)", () => { + it("accepts a complete draft and parses it", () => { + const result = validateRolloverConfig(validDraft); + expect(result.valid).toBe(true); + expect(result.config).toEqual({ + sector: "energy", + minYield: 6, + maxInvoiceAgeDays: 60, + maxAmount: 2000, + }); + }); + + it("requires a sector and every numeric criterion", () => { + const result = validateRolloverConfig({ + sector: "", + minYield: "", + maxInvoiceAgeDays: "", + maxAmount: "", + }); + expect(result.valid).toBe(false); + expect(result.config).toBeNull(); + expect(Object.keys(result.errors).sort()).toEqual( + ["maxAmount", "maxInvoiceAgeDays", "minYield", "sector"].sort(), + ); + }); + + it("rejects out-of-range values", () => { + const result = validateRolloverConfig({ + ...validDraft, + minYield: "101", + maxInvoiceAgeDays: "1.5", + maxAmount: "0", + }); + expect(result.errors.minYield).toBeDefined(); + expect(result.errors.maxInvoiceAgeDays).toBeDefined(); + expect(result.errors.maxAmount).toBeDefined(); + }); +}); + +describe("rollover mapping (#545)", () => { + it("seeds the form from a saved config, or defaults for a new one", () => { + expect(rolloverToDraft(null)).toEqual({ + sector: "", + minYield: "5", + maxInvoiceAgeDays: "30", + maxAmount: "1000", + }); + expect( + rolloverToDraft({ sector: "energy", minYield: 6, maxInvoiceAgeDays: 60, maxAmount: 2000 }), + ).toEqual(validDraft); + }); + + it("maps the wire payload to the domain config and back", () => { + const config = toRolloverConfig({ + sector: "energy", + min_yield: 6, + max_invoice_age: 60, + max_amount: 2000, + is_enabled: true, + }); + expect(config).toEqual({ + sector: "energy", + minYield: 6, + maxInvoiceAgeDays: 60, + maxAmount: 2000, + isEnabled: true, + updatedAt: undefined, + }); + expect(toRolloverConfigPayload(config!, false)).toEqual({ + sector: "energy", + min_yield: 6, + max_invoice_age: 60, + max_amount: 2000, + is_enabled: false, + }); + }); + + it("drops a config whose sector is no longer recognised", () => { + expect( + toRolloverConfig({ + sector: "retired_sector", + min_yield: 5, + max_invoice_age: 30, + max_amount: 1000, + is_enabled: true, + }), + ).toBeNull(); + }); +}); diff --git a/lib/api/index.ts b/lib/api/index.ts index f853e0d..7f54544 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -2704,6 +2704,92 @@ export async function fetchAutoInvestExecutedEvents( return res.json(); } +/* ─── Invoice auto-rollover (#545) ───────────────────────────────────────── + * At maturity, a funded position is reinvested into an invoice that matches + * the rollover criteria. Saving submits `set_rollover_config`, the kill switch + * submits `disable_rollover`, and the activity list is the `RolloverExecuted` + * event log indexed by the backend. + */ + +export interface RolloverConfigPayload { + sector: string; + min_yield: number; + max_invoice_age: number; + max_amount: number; + is_enabled: boolean; + updated_at?: string; +} + +export interface RolloverConfigResponse { + address: string; + /** `null` before the investor has ever configured a rollover. */ + config: RolloverConfigPayload | null; +} + +export async function fetchRolloverConfig( + address: string, + token?: string, +): Promise { + const params = new URLSearchParams({ address }); + const res = await fetch(`${API_BASE}/rollover/config?${params.toString()}`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to fetch rollover config"); + return res.json(); +} + +export async function setRolloverConfig( + address: string, + config: RolloverConfigPayload, + token?: string, +): Promise<{ success: boolean; tx_hash?: string }> { + const res = await fetch(`${API_BASE}/rollover/config`, { + method: "PUT", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ address, method: "set_rollover_config", ...config }), + }); + if (!res.ok) throw new Error("Failed to save rollover config"); + return res.json(); +} + +export async function disableRollover( + address: string, + token?: string, +): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/rollover/disable`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(token) }, + body: JSON.stringify({ address, method: "disable_rollover" }), + }); + if (!res.ok) throw new Error("Failed to disable rollover"); + return res.json(); +} + +/** One `RolloverExecuted` event: a matured position reinvested automatically. */ +export interface RolloverExecutedEvent { + id: string; + invoice_id: string; + invoice_title: string; + amount: number; + yield_percentage: number; + sector?: string; + executed_at: string; + tx_hash?: string; +} + +export async function fetchRolloverExecutedEvents( + address: string, + limit = 5, + token?: string, +): Promise { + const params = new URLSearchParams({ address, limit: String(limit) }); + const res = await fetch(`${API_BASE}/rollover/events?${params.toString()}`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error("Failed to fetch rollover activity"); + return res.json(); +} + // --------------------------------------------------------------------------- // Issuer announcements (#502) // --------------------------------------------------------------------------- diff --git a/lib/rollover.ts b/lib/rollover.ts new file mode 100644 index 0000000..8f37c37 --- /dev/null +++ b/lib/rollover.ts @@ -0,0 +1,144 @@ +import type { RolloverConfigPayload } from "@/lib/api"; +import { + isAutoInvestSector, + type AutoInvestSector, +} from "@/lib/autoInvest"; + +/** + * Invoice auto-rollover domain (#545). + * + * When a funded position matures, the protocol reinvests it into an invoice + * that matches the rollover criteria: one sector, a minimum yield, a maximum + * invoice age and a maximum amount. Sectors share the auto-invest taxonomy. + * + * Validation and wire mapping live here, free of React and network, so the + * form, the summary card and the tests agree on what a valid config is. + */ + +export interface RolloverConfigInput { + sector: AutoInvestSector; + minYield: number; + maxInvoiceAgeDays: number; + maxAmount: number; +} + +export interface RolloverConfig extends RolloverConfigInput { + isEnabled: boolean; + updatedAt?: string; +} + +/** Raw form state. Numbers are strings so an empty input reads as missing. */ +export interface RolloverDraft { + sector: string; + minYield: string; + maxInvoiceAgeDays: string; + maxAmount: string; +} + +export interface RolloverErrors { + sector?: string; + minYield?: string; + maxInvoiceAgeDays?: string; + maxAmount?: string; +} + +export interface RolloverValidationResult { + valid: boolean; + errors: RolloverErrors; + /** The parsed config, or `null` when validation failed. */ + config: RolloverConfigInput | null; +} + +const DEFAULT_DRAFT: RolloverDraft = { + sector: "", + minYield: "5", + maxInvoiceAgeDays: "30", + maxAmount: "1000", +}; + +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 validateRolloverConfig(draft: RolloverDraft): RolloverValidationResult { + const errors: RolloverErrors = {}; + + if (!isAutoInvestSector(draft.sector)) { + errors.sector = "Select a 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.maxAmount); + if (maxAmount === null) { + errors.maxAmount = "Maximum amount is required."; + } else if (maxAmount <= 0) { + errors.maxAmount = "Amount must be greater than 0."; + } + + const valid = Object.keys(errors).length === 0; + + return { + valid, + errors, + config: valid + ? { + sector: draft.sector as AutoInvestSector, + minYield: minYield as number, + maxInvoiceAgeDays: age as number, + maxAmount: maxAmount as number, + } + : null, + }; +} + +/** Seeds the editable form from a saved config, or the defaults for a new one. */ +export function rolloverToDraft(config: RolloverConfigInput | null | undefined): RolloverDraft { + if (!config) return { ...DEFAULT_DRAFT }; + return { + sector: config.sector, + minYield: String(config.minYield), + maxInvoiceAgeDays: String(config.maxInvoiceAgeDays), + maxAmount: String(config.maxAmount), + }; +} + +/** Maps the API response onto the domain config, dropping an unknown sector. */ +export function toRolloverConfig(payload: RolloverConfigPayload): RolloverConfig | null { + if (!isAutoInvestSector(payload.sector)) return null; + return { + sector: payload.sector, + minYield: payload.min_yield, + maxInvoiceAgeDays: payload.max_invoice_age, + maxAmount: payload.max_amount, + isEnabled: payload.is_enabled, + updatedAt: payload.updated_at, + }; +} + +export function toRolloverConfigPayload( + config: RolloverConfigInput, + isEnabled: boolean, +): RolloverConfigPayload { + return { + sector: config.sector, + min_yield: config.minYield, + max_invoice_age: config.maxInvoiceAgeDays, + max_amount: config.maxAmount, + is_enabled: isEnabled, + }; +}