Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions components/invoices/InvestmentAmountInput.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}
Expand All @@ -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<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

Expand All @@ -37,7 +42,7 @@ export function InvestmentAmountInput({
};
}, []);

function runValidation(rawValue: string) {
const runValidation = useCallback((rawValue: string) => {
if (rawValue.trim() === "") {
setError(null);
onErrorChange?.(null);
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down
28 changes: 27 additions & 1 deletion components/invoices/InvestmentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

Expand All @@ -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<number | null>(null);
const [amountError, setAmountError] = useState<string | null>(null);
const [currency, setCurrency] = useState<InvestCurrency>(BASE_CURRENCY);
Expand Down Expand Up @@ -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
Expand All @@ -95,7 +106,7 @@ export function InvestmentModal({
Invest
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<PopoverContent className="max-h-[80vh] w-[min(26rem,calc(100vw-2rem))] overflow-y-auto p-0">
{!isAcknowledged ? (
<AccreditationGateSteps
onCancel={() => setIsOpen(false)}
Expand Down Expand Up @@ -138,10 +149,25 @@ export function InvestmentModal({
<InvestmentAmountInput
min={minInvestment}
max={maxInvestment}
debounceMs={0}
value={amountRaw}
onValueChange={setAmountRaw}
onValidAmountChange={setValidAmount}
onErrorChange={setAmountError}
/>

{issuerWallet && maturityDate && (
<InvestmentProjection
issuerWallet={issuerWallet}
invoiceYieldPercent={yieldPercentage}
maturityDate={maturityDate}
fractionPrice={fractionPrice}
maxInvestment={maxInvestment}
amountRaw={amountRaw}
onAmountChange={setAmountRaw}
/>
)}

<InvestCurrencySelector
currency={currency}
onCurrencyChange={setCurrency}
Expand Down
154 changes: 154 additions & 0 deletions components/invoices/InvestmentProjection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { fetchDynamicYield, fetchFeeSchedule, fetchXlmUsdRate } from "@/lib/api";
import { formatFiat } from "@/lib/currency";
import { formatXLM } from "@/lib/format";
import { calculateInvestmentProjection } from "@/lib/investmentProjection";
import { daysUntil, parseAmount } from "@/lib/returnsCalculator";

interface InvestmentProjectionProps {
issuerWallet: string;
invoiceYieldPercent?: number | null;
maturityDate: string;
fractionPrice: number;
maxInvestment: number;
amountRaw: string;
onAmountChange: (value: string) => 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 (
<section className="space-y-3 border-t pt-4 text-sm" aria-label="Projected returns" data-testid="investment-projection">
<h4 className="font-semibold">Projected returns</h4>
<div className="space-y-1.5">
<Label htmlFor="projection-fractions">Fractions</Label>
<Input
id="projection-fractions"
data-testid="projection-fractions"
type="number"
inputMode="numeric"
min={0}
max={Math.floor(Math.round(maxInvestment * 10_000_000) / Math.round(validPrice * 10_000_000))}
step={1}
value={amountRaw === "" ? "" : fractionCount}
onChange={(event) => {
const count = Number(event.target.value);
if (event.target.value === "") onAmountChange("");
else if (Number.isSafeInteger(count) && count >= 0) {
onAmountChange(String(count * validPrice));
}
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="projection-hold">Hold until</Label>
<select
id="projection-hold"
data-testid="projection-hold"
className="h-9 w-full rounded-md border border-input bg-background px-3"
value={holdMode}
onChange={(event) => setHoldMode(event.target.value as "maturity" | "custom")}
>
<option value="maturity">Maturity</option>
<option value="custom">Custom date</option>
</select>
</div>
{holdMode === "custom" && (
<div className="space-y-1.5">
<Label htmlFor="projection-date">Hold date</Label>
<Input
id="projection-date"
data-testid="projection-date"
type="date"
min={new Date().toISOString().slice(0, 10)}
max={maturityDate.slice(0, 10)}
value={customDate}
aria-invalid={invalidCustomDate}
onChange={(event) => setCustomDate(event.target.value)}
/>
{invalidCustomDate && customDate && <p role="alert" className="text-destructive">Choose a date before maturity and after today.</p>}
</div>
)}
{result ? (
<dl className="space-y-1.5" data-testid="projection-results">
<div className="flex justify-between gap-3"><dt>Gross return</dt><dd className="text-right tabular-nums" data-testid="projection-gross">{formatProjection(result.grossProfit)}</dd></div>
<div className="flex justify-between gap-3"><dt>Platform fee ({feePercent}%)</dt><dd className="text-right tabular-nums" data-testid="projection-fee">-{formatProjection(result.investmentFee)}</dd></div>
<div className="flex justify-between gap-3 font-semibold"><dt>Net return</dt><dd className="text-right tabular-nums" data-testid="projection-net">{formatProjection(result.netProfit)}</dd></div>
<div className="flex justify-between gap-3"><dt>Net ROI on total paid</dt><dd className="tabular-nums" data-testid="projection-percent">{result.netReturnPercent.toFixed(2)}%</dd></div>
</dl>
) : (
<p className="text-muted-foreground" data-testid="projection-unavailable">
{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."}
</p>
)}
{result && amount - result.principal > 0.0000001 && (
<p className="text-xs text-muted-foreground" data-testid="projection-remainder">
{formatXLM(amount - result.principal)} does not buy a whole fraction and is excluded from this estimate.
</p>
)}
{!hasUsdRate && <p className="text-xs text-muted-foreground">USD estimate unavailable; amounts shown in XLM.</p>}
<p className="text-xs text-muted-foreground">
{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.
</p>
</section>
);
}
8 changes: 4 additions & 4 deletions components/invoices/InvoiceDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -120,6 +119,7 @@ export function InvoiceNotFound({ invoiceId }: { invoiceId: string }) {
*/
function InvestCallToAction({
invoiceId,
issuerWallet,
minInvestment,
maxInvestment,
fundingCap,
Expand All @@ -128,6 +128,7 @@ function InvestCallToAction({
fractionPrice,
}: {
invoiceId: string;
issuerWallet: string;
minInvestment: number;
maxInvestment: number;
fundingCap: number;
Expand Down Expand Up @@ -173,12 +174,10 @@ function InvestCallToAction({
<>
<InvestmentModal
invoiceId={invoiceId}
issuerWallet={issuerWallet}
minInvestment={minInvestment}
maxInvestment={maxInvestment}
fundingCap={fundingCap}
/>
<ReturnsCalculator
faceValue={fundingCap}
yieldPercentage={yieldPercentage}
maturityDate={maturityDate}
fractionPrice={fractionPrice}
Expand Down Expand Up @@ -428,6 +427,7 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) {
{isOpenForInvestment ? (
<InvestCallToAction
invoiceId={invoice.id}
issuerWallet={invoice.seller}
minInvestment={minInvestment}
maxInvestment={remaining}
fundingCap={invoice.amount}
Expand Down
Loading