diff --git a/components/invoices/InvestmentAmountInput.tsx b/components/invoices/InvestmentAmountInput.tsx index d226206..1d43c3a 100644 --- a/components/invoices/InvestmentAmountInput.tsx +++ b/components/invoices/InvestmentAmountInput.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -11,6 +11,8 @@ export interface InvestmentAmountInputProps { min: number; max: number; debounceMs?: number; + value?: string; + onValueChange?: (value: string) => void; onValidAmountChange?: (amount: number | null) => void; onErrorChange?: (error: string | null) => void; } @@ -19,10 +21,13 @@ export function InvestmentAmountInput({ min, max, debounceMs = 200, + value: controlledValue, + onValueChange, onValidAmountChange, onErrorChange, }: InvestmentAmountInputProps) { - const [value, setValue] = useState(""); + const [localValue, setLocalValue] = useState(""); + const value = controlledValue ?? localValue; const [error, setError] = useState(null); const timerRef = useRef | null>(null); @@ -37,7 +42,7 @@ export function InvestmentAmountInput({ }; }, []); - function runValidation(rawValue: string) { + const runValidation = useCallback((rawValue: string) => { if (rawValue.trim() === "") { setError(null); onErrorChange?.(null); @@ -49,10 +54,19 @@ export function InvestmentAmountInput({ setError(validationError); onErrorChange?.(validationError); onValidAmountChange?.(validationError ? null : Number(rawValue)); - } + }, [min, max, onErrorChange, onValidAmountChange]); + + useEffect(() => { + if (controlledValue !== undefined) runValidation(controlledValue); + }, [controlledValue, runValidation]); function handleChange(rawValue: string) { - setValue(rawValue); + if (controlledValue !== undefined) { + onValueChange?.(rawValue); + return; + } + setLocalValue(rawValue); + onValueChange?.(rawValue); if (timerRef.current) { clearTimeout(timerRef.current); @@ -69,7 +83,12 @@ export function InvestmentAmountInput({ function handlePreset(amount: number) { const str = String(amount); - setValue(str); + if (controlledValue !== undefined) { + onValueChange?.(str); + return; + } + setLocalValue(str); + onValueChange?.(str); if (timerRef.current) { clearTimeout(timerRef.current); } diff --git a/components/invoices/InvestmentModal.tsx b/components/invoices/InvestmentModal.tsx index 0a6240c..d53cc67 100644 --- a/components/invoices/InvestmentModal.tsx +++ b/components/invoices/InvestmentModal.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { InvestmentAmountInput } from "@/components/invoices/InvestmentAmountInput"; +import { InvestmentProjection } from "@/components/invoices/InvestmentProjection"; import { InvestCurrencySelector } from "@/components/invoices/InvestCurrencySelector"; import { FeeTierDisplay } from "@/components/invoices/FeeTierDisplay"; import { PriceImpactWarning } from "@/components/invoices/PriceImpactWarning"; @@ -35,6 +36,10 @@ interface InvestmentModalProps { maxInvestment: number; /** Total funding cap used for price-impact calculation (issue #344). */ fundingCap?: number; + issuerWallet?: string; + yieldPercentage?: number | null; + maturityDate?: string; + fractionPrice?: number; onSuccess?: () => void; } @@ -43,9 +48,14 @@ export function InvestmentModal({ minInvestment, maxInvestment, fundingCap, + issuerWallet, + yieldPercentage, + maturityDate, + fractionPrice = 1, onSuccess, }: InvestmentModalProps) { const [isOpen, setIsOpen] = useState(false); + const [amountRaw, setAmountRaw] = useState(""); const [validAmount, setValidAmount] = useState(null); const [amountError, setAmountError] = useState(null); const [currency, setCurrency] = useState(BASE_CURRENCY); @@ -80,6 +90,7 @@ export function InvestmentModal({ ...(isBaseCurrency ? {} : { currency }), }); setIsOpen(false); + setAmountRaw(""); setValidAmount(null); setAmountError(null); // The chain has moved; re-read the balance instead of waiting for the @@ -95,7 +106,7 @@ export function InvestmentModal({ Invest - + {!isAcknowledged ? ( setIsOpen(false)} @@ -138,10 +149,25 @@ export function InvestmentModal({ + {issuerWallet && maturityDate && ( + + )} + void; +} + +export function InvestmentProjection({ + issuerWallet, + invoiceYieldPercent, + maturityDate, + fractionPrice, + maxInvestment, + amountRaw, + onAmountChange, +}: InvestmentProjectionProps) { + const [holdMode, setHoldMode] = useState<"maturity" | "custom">("maturity"); + const [customDate, setCustomDate] = useState(""); + const issuerYield = useQuery({ + queryKey: ["dynamic-yield", issuerWallet], + queryFn: () => fetchDynamicYield(issuerWallet), + enabled: Boolean(issuerWallet), + }); + const feeSchedule = useQuery({ queryKey: ["fee-schedule"], queryFn: fetchFeeSchedule }); + const usdRate = useQuery({ queryKey: ["xlm-usd-rate"], queryFn: fetchXlmUsdRate }); + + const validPrice = Number.isFinite(fractionPrice) && fractionPrice >= 0.0000001 ? fractionPrice : 1; + const amount = parseAmount(amountRaw); + const amountUnits = Math.round(amount * 10_000_000); + const fractionCount = Math.floor(amountUnits / Math.round(validPrice * 10_000_000)); + const maturityDays = daysUntil(maturityDate); + const holdDays = holdMode === "maturity" ? maturityDays : daysUntil(customDate); + const invalidCustomDate = holdMode === "custom" && + (!customDate || holdDays <= 0 || holdDays > maturityDays); + const issuerRate = issuerYield.data?.current_rate; + const annualYieldPercent = issuerRate != null && Number.isFinite(issuerRate) && issuerRate >= 0 + ? issuerRate : invoiceYieldPercent; + const investmentFee = feeSchedule.data?.fees.find( + (fee) => fee.fee_type === "investment_fee" && fee.applies_to !== "issuer" + ); + const feePercent = investmentFee?.rate_percentage; + const canProject = Number.isSafeInteger(amountUnits) && Number.isSafeInteger(fractionCount) && fractionCount > 0 && + amount <= maxInvestment && !invalidCustomDate && maturityDays > 0 && + annualYieldPercent != null && Number.isFinite(annualYieldPercent) && annualYieldPercent >= 0 && + feePercent != null && Number.isFinite(feePercent) && feePercent >= 0 && feePercent <= 100; + const result = canProject ? calculateInvestmentProjection({ + fractionCount, + fractionPrice: validPrice, + annualYieldPercent, + holdDays, + investmentFeePercent: feePercent, + }) : null; + const xlmUsdRate = usdRate.data?.rate; + const hasUsdRate = xlmUsdRate != null && Number.isFinite(xlmUsdRate) && xlmUsdRate > 0; + const formatProjection = (xlm: number) => hasUsdRate + ? `${formatFiat(xlm * xlmUsdRate, "USD")} (${formatXLM(xlm)})` + : formatXLM(xlm); + + return ( +
+

Projected returns

+
+ + { + const count = Number(event.target.value); + if (event.target.value === "") onAmountChange(""); + else if (Number.isSafeInteger(count) && count >= 0) { + onAmountChange(String(count * validPrice)); + } + }} + /> +
+
+ + +
+ {holdMode === "custom" && ( +
+ + setCustomDate(event.target.value)} + /> + {invalidCustomDate && customDate &&

Choose a date before maturity and after today.

} +
+ )} + {result ? ( +
+
Gross return
{formatProjection(result.grossProfit)}
+
Platform fee ({feePercent}%)
-{formatProjection(result.investmentFee)}
+
Net return
{formatProjection(result.netProfit)}
+
Net ROI on total paid
{result.netReturnPercent.toFixed(2)}%
+
+ ) : ( +

+ {amount > maxInvestment ? "Amount exceeds the available investment." : + feeSchedule.isError || (feeSchedule.data && !investmentFee) ? "Current investment fee unavailable." : + annualYieldPercent == null ? "Issuer yield unavailable." : + "Enter fractions and a valid hold date to see your estimate."} +

+ )} + {result && amount - result.principal > 0.0000001 && ( +

+ {formatXLM(amount - result.principal)} does not buy a whole fraction and is excluded from this estimate. +

+ )} + {!hasUsdRate &&

USD estimate unavailable; amounts shown in XLM.

} +

+ {annualYieldPercent != null ? `${annualYieldPercent.toFixed(2)}% annual issuer yield` : "Issuer yield unavailable"} + {issuerYield.data ? " from issuer history" : " from invoice"}; {holdDays} projected days. + Estimate uses simple interest and the current investment fee. Early resale prices and fees are not included. +

+
+ ); +} diff --git a/components/invoices/InvoiceDetailView.tsx b/components/invoices/InvoiceDetailView.tsx index 9e340e7..cc4e79c 100644 --- a/components/invoices/InvoiceDetailView.tsx +++ b/components/invoices/InvoiceDetailView.tsx @@ -28,7 +28,6 @@ import { InvoiceMaturitySection } from "@/components/invoices/InvoiceMaturitySec import { InvoiceMetaTags } from "@/components/invoices/InvoiceMetaTags"; import { InvestorSocialProof } from "@/components/invoices/InvestorSocialProof"; import { InvoiceStatusBadge } from "@/components/invoices/InvoiceStatusBadge"; -import { ReturnsCalculator } from "@/components/invoices/ReturnsCalculator"; import { InvestmentModal } from "@/components/invoices/InvestmentModal"; import { InvoiceRatingWidget } from "@/components/invoices/InvoiceRatingWidget"; import { ReturnsBreakdown } from "@/components/invoices/ReturnsBreakdown"; @@ -120,6 +119,7 @@ export function InvoiceNotFound({ invoiceId }: { invoiceId: string }) { */ function InvestCallToAction({ invoiceId, + issuerWallet, minInvestment, maxInvestment, fundingCap, @@ -128,6 +128,7 @@ function InvestCallToAction({ fractionPrice, }: { invoiceId: string; + issuerWallet: string; minInvestment: number; maxInvestment: number; fundingCap: number; @@ -173,12 +174,10 @@ function InvestCallToAction({ <> - ({ + fetchDynamicYield: vi.fn().mockResolvedValue({ current_rate: 10 }), + fetchFeeSchedule: vi.fn().mockResolvedValue({ fees: [{ + fee_type: "investment_fee", applies_to: "investor", rate_percentage: 2, + }] }), + fetchXlmUsdRate: vi.fn().mockResolvedValue({ rate: 0.5 }), +})); + +function Harness() { + const [amountRaw, setAmountRaw] = useState(""); + return ( + <> + {amountRaw} + setAmountRaw(event.target.value)} /> + + + ); +} + +describe("InvestmentProjection", () => { + it("syncs fractions with the investment amount and updates the USD estimate", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + fireEvent.change(screen.getByTestId("projection-fractions"), { target: { value: "50" } }); + expect(screen.getByTestId("investment-amount")).toHaveTextContent("100"); + await waitFor(() => expect(screen.getByTestId("projection-net")).toHaveTextContent("$0.37")); + expect(screen.getByTestId("projection-fee")).toHaveTextContent("$1.00"); + expect(screen.getByTestId("projection-percent")).toHaveTextContent("0.73%"); + + fireEvent.change(screen.getByRole("textbox", { name: "Amount draft" }), { target: { value: "101" } }); + expect(screen.getByTestId("projection-fractions")).toHaveValue(50); + expect(screen.getByTestId("projection-remainder")).toHaveTextContent("1.00 XLM"); + + fireEvent.change(screen.getByRole("textbox", { name: "Amount draft" }), { target: { value: "1001" } }); + expect(screen.queryByTestId("projection-results")).not.toBeInTheDocument(); + expect(screen.getByTestId("projection-unavailable")).toHaveTextContent("exceeds"); + + fireEvent.change(screen.getByRole("textbox", { name: "Amount draft" }), { target: { value: "100" } }); + + fireEvent.change(screen.getByTestId("projection-hold"), { target: { value: "custom" } }); + const customDate = new Date(Date.now() + 50 * 86_400_000).toISOString().slice(0, 10); + fireEvent.change(screen.getByTestId("projection-date"), { target: { value: customDate } }); + expect(screen.getByTestId("projection-gross")).toHaveTextContent("$0.68"); + }); +}); diff --git a/lib/__tests__/investmentProjection.test.ts b/lib/__tests__/investmentProjection.test.ts new file mode 100644 index 0000000..0e66aa2 --- /dev/null +++ b/lib/__tests__/investmentProjection.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { calculateInvestmentProjection } from "@/lib/investmentProjection"; + +describe("calculateInvestmentProjection", () => { + const base = { + fractionCount: 100, + fractionPrice: 2, + annualYieldPercent: 12, + holdDays: 73, + investmentFeePercent: 1, + }; + + it("deducts the current investment fee from pro-rated gross profit", () => { + const result = calculateInvestmentProjection(base); + expect(result.principal).toBe(200); + expect(result.grossProfit).toBeCloseTo(4.8); + expect(result.investmentFee).toBe(2); + expect(result.totalOutlay).toBe(202); + expect(result.netProfit).toBeCloseTo(2.8); + expect(result.netReturnPercent).toBeCloseTo(2.8 / 202 * 100); + }); + + it("reduces yield for an earlier hold date without reducing the upfront fee", () => { + const result = calculateInvestmentProjection({ ...base, holdDays: 36 }); + expect(result.grossProfit).toBeCloseTo(200 * 0.12 * 36 / 365); + expect(result.investmentFee).toBe(2); + }); + + it("does not fabricate returns for empty or invalid fractions", () => { + expect(calculateInvestmentProjection({ ...base, fractionCount: 0 }).netProfit).toBe(0); + expect(calculateInvestmentProjection({ ...base, fractionCount: 1.5 }).principal).toBe(0); + }); +}); diff --git a/lib/api/index.ts b/lib/api/index.ts index 99d71aa..e99772c 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -146,6 +146,20 @@ export interface InvoiceDetail extends Invoice { }; } +export interface DynamicYieldResponse { + current_rate: number; + base_rate: number; + settlement_count: number; + adjustment_history: { rate: number; effective_at: string; reason?: string }[]; +} + +export async function fetchDynamicYield(wallet: string): Promise { + const res = await fetch(`${API_BASE}/issuers/${encodeURIComponent(wallet)}/dynamic-yield`); + if (res.status === 404) return null; + if (!res.ok) throw new Error("Failed to fetch issuer yield history"); + return res.json(); +} + export interface InvoicesResponse { invoices: Invoice[]; has_more: boolean; diff --git a/lib/investmentProjection.ts b/lib/investmentProjection.ts new file mode 100644 index 0000000..a4554e5 --- /dev/null +++ b/lib/investmentProjection.ts @@ -0,0 +1,35 @@ +import { DAYS_PER_YEAR } from "@/lib/returnsCalculator"; + +export interface InvestmentProjectionInput { + fractionCount: number; + fractionPrice: number; + annualYieldPercent: number; + holdDays: number; + investmentFeePercent: number; +} + +export function calculateInvestmentProjection(input: InvestmentProjectionInput) { + const fractions = Number.isSafeInteger(input.fractionCount) && input.fractionCount > 0 + ? input.fractionCount : 0; + const price = Number.isFinite(input.fractionPrice) && input.fractionPrice > 0 + ? input.fractionPrice : 0; + const principal = fractions * price; + const yieldPercent = Number.isFinite(input.annualYieldPercent) + ? Math.max(0, input.annualYieldPercent) : 0; + const days = Number.isFinite(input.holdDays) ? Math.max(0, input.holdDays) : 0; + const projectedYield = principal * yieldPercent / 100 * days / DAYS_PER_YEAR; + const feePercent = Number.isFinite(input.investmentFeePercent) + ? Math.min(100, Math.max(0, input.investmentFeePercent)) : 0; + const investmentFee = principal * feePercent / 100; + const netProfit = projectedYield - investmentFee; + const totalOutlay = principal + investmentFee; + + return { + principal, + grossProfit: projectedYield, + investmentFee, + totalOutlay, + netProfit, + netReturnPercent: totalOutlay > 0 ? netProfit / totalOutlay * 100 : 0, + }; +}