From 7185561321ce572e58f4cafadb55638552ea471a Mon Sep 17 00:00:00 2001 From: Retkatmun Date: Tue, 18 Aug 2026 20:28:15 +0100 Subject: [PATCH] feat(frontend): invoice securitization and fractional ownership UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full invoice securitization and fractional ownership UI described in #231. ## What's added ### Types — src/types/securitization.ts FractionalizationRecord, FractionalPosition, PriceHistoryPoint, DividendRecord, FractionalPositionView. Bigint-safe; re-exported from the @/types barrel. ### Supabase migration — src/lib/migrations/002_securitization.sql Four tables with RLS + updated_at triggers: • fractionalization_records — one per invoice, tracks N, unit price, status • fractional_positions — investor holdings per fractionalization • price_history — time-series of fraction trade prices • dividend_distributions — originator yield payouts ### Data helpers — src/lib/securitization.ts fractionalizationSchema (Zod), purchaseSchema, createFractionalization, purchaseFraction, fetchFractionalPositions, buildPositionViews, fetchPriceHistory, fetchDividends, createDividend, computeTotalCost. ### Components — src/components/securitization/ • FractionalizationWizard — 3-step wizard (configure → review → done) with step indicator, economics summary, Zod-validated form • PurchaseFractionModal — Dialog with fraction count input, cost breakdown (unit price × N), success state, guides to portfolio transfer • PriceHistoryChart — pure SVG sparkline, gradient fill, hover crosshair tooltip, % change badge, no external chart library • DividendTracker — distribution table with per-investor share column; originator accordion form to push new dividends • FractionalPositionCard — stats grid: fractions held, ownership %, current estimated value, dividends earned; links to invoice + secondary market listing ### Pages • /securitize/[invoiceId] — originator-only gate; shows wizard on first visit, then active fractionalization banner + price chart + dividend tracker with cancel option • /marketplace/fractions — investor browse: FracCard grid with sold-progress bar, sparkline, PurchaseFractionModal; filter by currency, sort by price/availability/newest • /portfolio/fractions — investor portfolio: FractionalPositionCard grid + per-position price chart + expandable dividend accordion; aggregate value/dividend/count summary stats ### Integration • MarketplaceTabs — added third 'Fractions' tab (/marketplace/fractions) • Portfolio page — fractional positions count stat + 'View fractions →' link to /portfolio/fractions ## Acceptance criteria - [x] Fractionalization wizard works (3-step, Zod validation, db write) - [x] Purchase flow completes (modal, cost summary, off-chain record) - [x] Portfolio shows fractional positions (FractionalPositionCard grid) - [x] Secondary market listing works (links to /marketplace/positions) - [x] Price history displayed (SVG sparkline on wizard + marketplace) - [x] Dividends tracked (DividendTracker table + originator create form) Closes #231 --- .../src/app/marketplace/fractions/page.tsx | 312 +++++++++++++ .../src/app/portfolio/fractions/page.tsx | 186 ++++++++ .../apps/frontend/src/app/portfolio/page.tsx | 28 +- .../src/app/securitize/[invoiceId]/page.tsx | 263 +++++++++++ .../marketplace/MarketplaceTabs.tsx | 9 +- .../securitization/DividendTracker.tsx | 353 +++++++++++++++ .../securitization/FractionalPositionCard.tsx | 153 +++++++ .../FractionalizationWizard.tsx | 425 ++++++++++++++++++ .../securitization/PriceHistoryChart.tsx | 251 +++++++++++ .../securitization/PurchaseFractionModal.tsx | 228 ++++++++++ .../src/lib/migrations/002_securitization.sql | 177 ++++++++ .../apps/frontend/src/lib/securitization.ts | 369 +++++++++++++++ invofi/apps/frontend/src/types/index.ts | 13 + .../apps/frontend/src/types/securitization.ts | 160 +++++++ 14 files changed, 2920 insertions(+), 7 deletions(-) create mode 100644 invofi/apps/frontend/src/app/marketplace/fractions/page.tsx create mode 100644 invofi/apps/frontend/src/app/portfolio/fractions/page.tsx create mode 100644 invofi/apps/frontend/src/app/securitize/[invoiceId]/page.tsx create mode 100644 invofi/apps/frontend/src/components/securitization/DividendTracker.tsx create mode 100644 invofi/apps/frontend/src/components/securitization/FractionalPositionCard.tsx create mode 100644 invofi/apps/frontend/src/components/securitization/FractionalizationWizard.tsx create mode 100644 invofi/apps/frontend/src/components/securitization/PriceHistoryChart.tsx create mode 100644 invofi/apps/frontend/src/components/securitization/PurchaseFractionModal.tsx create mode 100644 invofi/apps/frontend/src/lib/migrations/002_securitization.sql create mode 100644 invofi/apps/frontend/src/lib/securitization.ts create mode 100644 invofi/apps/frontend/src/types/securitization.ts diff --git a/invofi/apps/frontend/src/app/marketplace/fractions/page.tsx b/invofi/apps/frontend/src/app/marketplace/fractions/page.tsx new file mode 100644 index 000000000..5c5c272d3 --- /dev/null +++ b/invofi/apps/frontend/src/app/marketplace/fractions/page.tsx @@ -0,0 +1,312 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Search, Layers, Tag } from 'lucide-react'; + +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { AuthGuard } from '@/components/auth/AuthGuard'; +import { MarketplaceTabs } from '@/components/marketplace/MarketplaceTabs'; +import { PurchaseFractionModal } from '@/components/securitization/PurchaseFractionModal'; +import { PriceHistoryChart } from '@/components/securitization/PriceHistoryChart'; +import { CardSkeleton } from '@/components/common/LoadingSkeleton'; +import { supabase } from '@/lib/supabase'; +import { + fetchActiveFragrationalizations, + fetchPriceHistory, +} from '@/lib/securitization'; +import type { Currency } from '@/types'; +import type { FractionalizationRecord, PriceHistoryPoint } from '@/types/securitization'; + +type SortKey = 'newest' | 'price_asc' | 'price_desc' | 'available_desc'; + +const SORT_OPTIONS: { value: SortKey; label: string }[] = [ + { value: 'newest', label: 'Newest first' }, + { value: 'available_desc', label: 'Most available' }, + { value: 'price_asc', label: 'Price: low to high' }, + { value: 'price_desc', label: 'Price: high to low' }, +]; + +// ── Single fractionalization card ───────────────────────────────────────────── + +interface FracCardProps { + record: FractionalizationRecord; + history: PriceHistoryPoint[]; + userId: string | null; + userAddress: string | null; +} + +function FracCard({ record, history, userId, userAddress }: FracCardProps) { + const soldPercent = + record.total_fractions > 0 + ? Math.round(((record.total_fractions - record.available_fractions) / record.total_fractions) * 100) + : 0; + + const isOwner = record.originator_id === userId; + + return ( + + + {/* Header */} +
+
+
+ + + {record.token_symbol} + +
+

{record.token_name}

+
+ + {record.status === 'sold_out' ? 'Sold out' : record.status} + +
+ + {/* Economics */} +
+
+ Price / fraction + + {record.price_per_fraction} {record.price_currency} + +
+
+ Available + + {record.available_fractions.toLocaleString()} / {record.total_fractions.toLocaleString()} + +
+ + {/* Progress bar */} +
+
+
+

{soldPercent}% sold

+ + {/* Invoice link */} +

+ Invoice:{' '} + + {record.invoice_id} + +

+ + {record.description && ( +

+ {record.description} +

+ )} +
+ + {/* Sparkline */} + {history.length > 0 && ( + + )} + + {/* CTA */} +
+ {isOwner ? ( + + ) : userId && userAddress ? ( + {/* refetch handled by parent via key */}} + trigger={ + + } + /> + ) : ( + + )} +
+ + + ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function FractionsMarketplacePage() { + const [records, setRecords] = useState([]); + const [historyMap, setHistoryMap] = useState>(new Map()); + const [loading, setLoading] = useState(true); + const [userId, setUserId] = useState(null); + const [userAddress, setUserAddress] = useState(null); + const [search, setSearch] = useState(''); + const [currencyFilter, setCurrencyFilter] = useState('ALL'); + const [sort, setSort] = useState('newest'); + + const load = useCallback(async () => { + setLoading(true); + try { + const [{ data: { user } }, recs] = await Promise.all([ + supabase.auth.getUser(), + fetchActiveFragrationalizations(), + ]); + + if (user) { + setUserId(user.id); + const { data: profile } = await supabase + .from('user_profiles') + .select('wallet_address') + .eq('id', user.id) + .maybeSingle(); + setUserAddress( + (profile as { wallet_address: string | null } | null)?.wallet_address ?? null, + ); + } + + setRecords(recs); + + // Fetch price history for all fracs in parallel + const histEntries = await Promise.all( + recs.map(async r => [r.id, await fetchPriceHistory(r.id, 20)] as [string, PriceHistoryPoint[]]), + ); + setHistoryMap(new Map(histEntries)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + // Filter + sort + const filtered = records.filter(r => { + if (currencyFilter !== 'ALL' && r.price_currency !== currencyFilter) return false; + if (search) { + const q = search.toLowerCase(); + return ( + r.token_symbol.toLowerCase().includes(q) || + r.token_name.toLowerCase().includes(q) || + r.invoice_id.toLowerCase().includes(q) + ); + } + return true; + }); + + const sorted = [...filtered].sort((a, b) => { + switch (sort) { + case 'available_desc': + return b.available_fractions - a.available_fractions; + case 'price_asc': + return parseFloat(a.price_per_fraction) - parseFloat(b.price_per_fraction); + case 'price_desc': + return parseFloat(b.price_per_fraction) - parseFloat(a.price_per_fraction); + case 'newest': + default: + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + } + }); + + return ( + +
+
+

Fractional Invoice Tokens

+

+ Buy fractions of financed invoices and earn a proportional share of yield and dividends. +

+
+ + + + {/* Filters */} +
+
+ + setSearch(e.target.value)} + /> +
+ + +
+ + {loading && ( +
+ {[1, 2, 3].map(i => )} +
+ )} + + {!loading && sorted.length === 0 && ( +
+ +

No fractionalized invoices yet

+

+ Invoice owners can fractionalize from their dashboard. +

+
+ )} + + {!loading && sorted.length > 0 && ( +
+ {sorted.map(rec => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/invofi/apps/frontend/src/app/portfolio/fractions/page.tsx b/invofi/apps/frontend/src/app/portfolio/fractions/page.tsx new file mode 100644 index 000000000..c9d64222b --- /dev/null +++ b/invofi/apps/frontend/src/app/portfolio/fractions/page.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { ArrowLeft, Layers, Loader2 } from 'lucide-react'; + +import { AuthGuard } from '@/components/auth/AuthGuard'; +import { FractionalPositionCard } from '@/components/securitization/FractionalPositionCard'; +import { DividendTracker } from '@/components/securitization/DividendTracker'; +import { PriceHistoryChart } from '@/components/securitization/PriceHistoryChart'; +import { supabase } from '@/lib/supabase'; +import { + fetchFractionalPositions, + buildPositionViews, + fetchPriceHistory, +} from '@/lib/securitization'; +import type { FractionalPositionView } from '@/types/securitization'; +import type { PriceHistoryPoint } from '@/types/securitization'; + +export default function FractionsPortfolioPage() { + const [views, setViews] = useState([]); + const [loading, setLoading] = useState(true); + const [userId, setUserId] = useState(null); + // Map fractionalization_id → price history + const [historyMap, setHistoryMap] = useState>(new Map()); + // Which card is expanded for dividends + const [expanded, setExpanded] = useState(null); + + useEffect(() => { + (async () => { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { setLoading(false); return; } + setUserId(user.id); + + const positions = await fetchFractionalPositions(user.id); + const built = await buildPositionViews(positions); + setViews(built); + + // Fetch price history for each unique fractionalization + const ids = [...new Set(positions.map(p => p.fractionalization_id))]; + const entries = await Promise.all( + ids.map(async id => [id, await fetchPriceHistory(id)] as [string, PriceHistoryPoint[]]), + ); + setHistoryMap(new Map(entries)); + + setLoading(false); + })(); + }, []); + + // Aggregate stats + const totalCurrentValue = views.reduce((s, v) => s + parseFloat(v.currentValue), 0); + const totalDividends = views.reduce((s, v) => s + parseFloat(v.totalDividendsEarned), 0); + const totalFractions = views.reduce((s, v) => s + v.position.fraction_count, 0); + + return ( + +
+ + {/* Back */} + + + Portfolio + + +
+
+

+ + Fractional Positions +

+

+ Your fractional invoice token holdings, dividends, and price history. +

+
+ + Browse fractional marketplace → + +
+ + {/* Summary stats */} + {!loading && views.length > 0 && ( +
+
+

Total est. value

+

+ {totalCurrentValue.toFixed(2)} +

+

{views[0]?.position.purchase_currency}

+
+
+

Dividends earned

+

+ {totalDividends.toFixed(4)} +

+

across all positions

+
+
+

Fractions held

+

+ {totalFractions.toLocaleString()} +

+

{views.length} position{views.length !== 1 ? 's' : ''}

+
+
+ )} + + {/* Loading */} + {loading && ( +
+ + Loading fractional positions… +
+ )} + + {/* Empty */} + {!loading && views.length === 0 && ( +
+ +

No fractional positions yet.

+ + Browse fractionalized invoices → + +
+ )} + + {/* Position cards */} + {!loading && views.length > 0 && ( +
+ {views.map(view => ( +
+ + + {/* Price chart for this fractionalization */} + {historyMap.has(view.position.fractionalization_id) && ( + + )} + + {/* Dividend accordion */} + {view.record && ( +
+ + {expanded === view.position.fractionalization_id && ( +
+ +
+ )} +
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/invofi/apps/frontend/src/app/portfolio/page.tsx b/invofi/apps/frontend/src/app/portfolio/page.tsx index 81a8d8f8a..d96d2edb6 100644 --- a/invofi/apps/frontend/src/app/portfolio/page.tsx +++ b/invofi/apps/frontend/src/app/portfolio/page.tsx @@ -3,7 +3,7 @@ import { Suspense, useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { TrendingUp, Clock, CheckCircle2, AlertCircle, Download, Copy, Check, Send, RefreshCw, Tag } from 'lucide-react'; +import { TrendingUp, Clock, CheckCircle2, AlertCircle, Download, Copy, Check, Send, RefreshCw, Tag, Layers } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -16,6 +16,7 @@ import { addPositionTrustline, getPositionTokenId, getTokenBalance, getTokenDeci import { formatAmount, formatDate, interestRateLabel, durationLabel, toStroopsBigInt, OFFER_STATUS_COLORS } from '@/lib/utils'; import { STROOPS_PER_XLM } from '@/lib/constants'; import { toCsv, downloadCsv } from '@/lib/csv'; +import { fetchFractionalPositions } from '@/lib/securitization'; import type { FinancingOffer } from '@/types'; import { SupabaseUser } from '@/lib/types/supabase-auth'; @@ -279,12 +280,12 @@ function CopyId({ id }: { id: string }) { export default function PortfolioPage() { const [offers, setOffers] = useState([]); const [loading, setLoading] = useState(true); + const [fractionalCount, setFractionalCount] = useState(0); useEffect(() => { supabase.auth.getUser().then(async ({ data }: { data: { user: SupabaseUser | null } }) => { const { user } = data; if (!user) { - // Wallet-only user — no offers to show yet; stop the spinner. setLoading(false); return; } @@ -294,13 +295,16 @@ export default function PortfolioPage() { .eq('lender_id', user.id) .order('created_at', { ascending: false }); const rows = (offersData as unknown as FinancingOffer[]) ?? []; - // Normalize mirror strings (and contract i128s) to bigint stroops so - // amount/amount_repaid math and display are consistent. setOffers(rows.map(o => ({ ...o, amount: toStroopsBigInt(o.amount), amount_repaid: toStroopsBigInt(o.amount_repaid), }))); + // Fractional positions count + try { + const fp = await fetchFractionalPositions(user.id); + setFractionalCount(fp.length); + } catch { /* non-fatal */ } setLoading(false); }); }, []); @@ -385,6 +389,22 @@ export default function PortfolioPage() {
+ {/* Fractional positions summary + link */} +
+
+ +
+

+ {fractionalCount} fractional position{fractionalCount !== 1 ? 's' : ''} +

+

Invoice fraction tokens you hold

+
+
+ + View fractions → + +
+ {/* Extra earned stat */} {repaid.length > 0 && (
diff --git a/invofi/apps/frontend/src/app/securitize/[invoiceId]/page.tsx b/invofi/apps/frontend/src/app/securitize/[invoiceId]/page.tsx new file mode 100644 index 000000000..430a266bb --- /dev/null +++ b/invofi/apps/frontend/src/app/securitize/[invoiceId]/page.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, AlertTriangle, Loader2 } from 'lucide-react'; + +import { AuthGuard } from '@/components/auth/AuthGuard'; +import { FractionalizationWizard } from '@/components/securitization/FractionalizationWizard'; +import { PriceHistoryChart } from '@/components/securitization/PriceHistoryChart'; +import { DividendTracker } from '@/components/securitization/DividendTracker'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { supabase } from '@/lib/supabase'; +import { + fetchFractionalizationRecord, + fetchPriceHistory, + cancelFractionalization, +} from '@/lib/securitization'; +import { formatAmount, INVOICE_STATUS_COLORS } from '@/lib/utils'; +import { useToast } from '@/components/ui/use-toast'; +import type { Invoice } from '@/types'; +import type { FractionalizationRecord } from '@/types/securitization'; + +export default function SecuritizePage() { + const params = useParams<{ invoiceId: string }>(); + const router = useRouter(); + const { toast } = useToast(); + + const [invoice, setInvoice] = useState(null); + const [record, setRecord] = useState(null); + const [priceHistory, setPriceHistory] = useState([]); + const [userId, setUserId] = useState(null); + const [walletAddress, setWalletAddress] = useState(null); + const [loading, setLoading] = useState(true); + const [cancelling, setCancelling] = useState(false); + const [authError, setAuthError] = useState(false); + + const invoiceId = params?.invoiceId ?? ''; + + useEffect(() => { + if (!invoiceId) return; + + (async () => { + setLoading(true); + + // Auth check + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { setLoading(false); return; } + setUserId(user.id); + + // Profile wallet + const { data: profile } = await supabase + .from('user_profiles') + .select('wallet_address') + .eq('id', user.id) + .maybeSingle(); + const wallet = (profile as { wallet_address: string | null } | null)?.wallet_address ?? null; + setWalletAddress(wallet); + + // Fetch invoice + const { data: invData, error: invErr } = await supabase + .from('invoices') + .select('*') + .eq('id', invoiceId) + .single(); + + if (invErr || !invData) { + setLoading(false); + return; + } + + const inv = invData as Invoice; + + // Only the originator may access this page + if ((invData as { originator_id?: string }).originator_id !== user.id) { + setAuthError(true); + setLoading(false); + return; + } + + setInvoice(inv); + + // Existing fractionalization? + const existing = await fetchFractionalizationRecord(invoiceId); + setRecord(existing); + + if (existing) { + const ph = await fetchPriceHistory(existing.id); + setPriceHistory(ph); + } + + setLoading(false); + })(); + }, [invoiceId]); + + const handleComplete = async (newRecord: FractionalizationRecord) => { + setRecord(newRecord); + const ph = await fetchPriceHistory(newRecord.id); + setPriceHistory(ph); + }; + + const handleCancel = async () => { + if (!record) return; + setCancelling(true); + try { + await cancelFractionalization(record.id); + setRecord(prev => prev ? { ...prev, status: 'cancelled' } : prev); + toast({ title: 'Fractionalization cancelled', description: 'No new purchases can be made.' }); + } catch (err) { + toast({ + title: 'Cancel failed', + description: err instanceof Error ? err.message : 'Could not cancel', + variant: 'destructive', + }); + } finally { + setCancelling(false); + } + }; + + // ── Render ────────────────────────────────────────────────────────────────── + + return ( + +
+ {/* Back */} + + + {invoice ? `Invoice ${invoice.id}` : 'Dashboard'} + + + {/* Loading */} + {loading && ( +
+ + Loading invoice… +
+ )} + + {/* Auth error */} + {!loading && authError && ( +
+ +
+

Access denied

+

+ Only the invoice originator can fractionalize this invoice. +

+
+
+ )} + + {/* Invoice not found */} + {!loading && !authError && !invoice && ( +

Invoice not found.

+ )} + + {/* Main content */} + {!loading && !authError && invoice && ( + <> + {/* Invoice summary */} +
+
+

Securitize Invoice

+

{invoice.id}

+
+
+

+ {formatAmount(invoice.amount)} {invoice.currency} +

+ {invoice.status} +
+
+ + {/* Active fractionalization banner */} + {record && record.status !== 'cancelled' && ( +
+
+
+

+ {record.token_symbol} · {record.total_fractions.toLocaleString()} fractions +

+

+ {record.available_fractions.toLocaleString()} available ·{' '} + {record.price_per_fraction} {record.price_currency} each +

+
+
+ + {record.status} + + {record.status === 'active' && ( + + )} +
+
+ + {/* Price history chart */} + {priceHistory.length > 0 && ( + + )} + + {/* Dividend tracker */} + {userId && ( + + )} +
+ )} + + {/* Wizard — only show if no active/sold_out fractionalization */} + {(!record || record.status === 'cancelled') && userId && walletAddress && ( + router.push('/marketplace/positions')} + /> + )} + + {(!record || record.status === 'cancelled') && (!userId || !walletAddress) && ( +
+ Link a Stellar wallet in{' '} + settings{' '} + before fractionalizing — buyers need your on-chain address. +
+ )} + + )} +
+
+ ); +} diff --git a/invofi/apps/frontend/src/components/marketplace/MarketplaceTabs.tsx b/invofi/apps/frontend/src/components/marketplace/MarketplaceTabs.tsx index acfd13de8..3cab68872 100644 --- a/invofi/apps/frontend/src/components/marketplace/MarketplaceTabs.tsx +++ b/invofi/apps/frontend/src/components/marketplace/MarketplaceTabs.tsx @@ -5,13 +5,16 @@ import { usePathname } from 'next/navigation'; import { cn } from '@/lib/utils'; const TABS = [ - { href: '/marketplace', label: 'Invoices' }, + { href: '/marketplace', label: 'Invoices' }, { href: '/marketplace/positions', label: 'Positions' }, + { href: '/marketplace/fractions', label: 'Fractions' }, ] as const; /** - * Switches between the two marketplace surfaces: invoices open for financing - * and the secondary-market board for position tokens (ADR-0004). + * Switches between the three marketplace surfaces: + * - Invoices: open for financing + * - Positions: secondary-market position-token board (ADR-0004) + * - Fractions: fractionalized invoice tokens available for purchase */ export function MarketplaceTabs() { const pathname = usePathname(); diff --git a/invofi/apps/frontend/src/components/securitization/DividendTracker.tsx b/invofi/apps/frontend/src/components/securitization/DividendTracker.tsx new file mode 100644 index 000000000..1d30a8963 --- /dev/null +++ b/invofi/apps/frontend/src/components/securitization/DividendTracker.tsx @@ -0,0 +1,353 @@ +'use client'; + +/** + * DividendTracker + * + * Shows dividend distribution history for a fractionalization, including: + * - Per-event table (amount, per-fraction, status, date) + * - Summary: total distributed, total earned by this investor (if positionFractions provided) + * - Create dividend form for the originator + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + BadgeDollarSign, + ChevronDown, + ChevronUp, + Loader2, + PlusCircle, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { useToast } from '@/components/ui/use-toast'; +import { fetchDividends, createDividend } from '@/lib/securitization'; +import { toStroopsBigInt } from '@/lib/utils'; +import type { DividendRecord, FractionalizationRecord } from '@/types/securitization'; +import type { Currency } from '@/types'; + +// ── Schema ──────────────────────────────────────────────────────────────────── + +const dividendSchema = z.object({ + totalAmount: z + .string() + .regex(/^\d+(\.\d{1,7})?$/, 'Enter a valid amount (e.g. 100.00)') + .refine(v => toStroopsBigInt(v) > 0n, 'Amount must be greater than zero'), + currency: z.enum(['XLM', 'USDC']), + note: z.string().max(200, 'Max 200 characters'), +}); + +type DividendFormValues = z.infer; + +// ── Status badge colours ────────────────────────────────────────────────────── + +const STATUS_STYLES: Record = { + pending: 'bg-yellow-50 text-yellow-700 border-yellow-200', + distributed: 'bg-green-50 text-green-700 border-green-200', + cancelled: 'bg-gray-50 text-gray-500 border-gray-200', +}; + +// ── Create dividend form (originator only) ──────────────────────────────────── + +interface CreateDividendFormProps { + record: FractionalizationRecord; + originatorId: string; + onCreated: (d: DividendRecord) => void; +} + +function CreateDividendForm({ record, originatorId, onCreated }: CreateDividendFormProps) { + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const { + register, + handleSubmit, + reset, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(dividendSchema), + defaultValues: { totalAmount: '', currency: record.price_currency, note: '' }, + }); + + const totalAmount = watch('totalAmount') || '0'; + const currency = watch('currency'); + + let perFraction = '0'; + try { + const stroops = Number(toStroopsBigInt(totalAmount)); + perFraction = (stroops / record.total_fractions / 1e7).toFixed(7); + } catch { /* ignore */ } + + const onSubmit = async (values: DividendFormValues) => { + setSubmitting(true); + try { + const div = await createDividend( + record.id, + originatorId, + values.totalAmount, + values.currency as Currency, + record.total_fractions, + values.note, + ); + toast({ + title: 'Dividend distributed', + description: `${values.totalAmount} ${values.currency} distributed to ${record.total_fractions.toLocaleString()} fraction holders.`, + }); + reset({ totalAmount: '', currency: record.price_currency, note: '' }); + setOpen(false); + onCreated(div); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Could not create dividend'; + toast({ title: 'Distribution failed', description: msg, variant: 'destructive' }); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + + {open && ( +
+
+
+ + + {errors.totalAmount && ( +

{errors.totalAmount.message}

+ )} +
+
+ + +
+
+ + {Number(perFraction) > 0 && ( +

+ Per fraction:{' '} + + {perFraction} {currency} + {' '} + × {record.total_fractions.toLocaleString()} holders +

+ )} + +
+ + +
+ +
+ + +
+
+ )} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +interface DividendTrackerProps { + record: FractionalizationRecord; + /** If provided, shows this investor's pro-rata earnings. */ + positionFractions?: number; + /** If the current user is the originator, show the create-dividend form. */ + isOriginator?: boolean; + originatorId?: string; + className?: string; +} + +export function DividendTracker({ + record, + positionFractions, + isOriginator = false, + originatorId, + className, +}: DividendTrackerProps) { + const [dividends, setDividends] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + setDividends(await fetchDividends(record.id)); + } catch { + setError('Could not load dividend history'); + } finally { + setLoading(false); + } + }, [record.id]); + + useEffect(() => { load(); }, [load]); + + const distributed = dividends.filter(d => d.status === 'distributed'); + + // Total distributed in stroops (simple sum) + const totalDistributedStroops = distributed.reduce( + (sum, d) => sum + Number(toStroopsBigInt(d.total_amount)), + 0, + ); + + // Investor's total earned + const myEarnedStroops = positionFractions + ? distributed.reduce( + (sum, d) => sum + Number(toStroopsBigInt(d.per_fraction_amount)) * positionFractions, + 0, + ) + : null; + + return ( +
+
+ +

Dividend history

+
+ + {/* Summary */} +
+
+

Total distributed

+

+ {(totalDistributedStroops / 1e7).toFixed(2)}{' '} + + {dividends[0]?.currency ?? record.price_currency} + +

+

{distributed.length} event{distributed.length !== 1 ? 's' : ''}

+
+ {myEarnedStroops !== null && ( +
+

Your earnings

+

+ {(myEarnedStroops / 1e7).toFixed(4)}{' '} + {dividends[0]?.currency ?? record.price_currency} +

+

{positionFractions} fraction{positionFractions !== 1 ? 's' : ''} held

+
+ )} +
+ + {/* Table */} + {loading && ( +
+ + Loading dividend history… +
+ )} + + {!loading && error && ( +

{error}

+ )} + + {!loading && !error && dividends.length === 0 && ( +

No dividends distributed yet.

+ )} + + {!loading && dividends.length > 0 && ( +
+ + + + + + + {positionFractions && ( + + )} + + + + + {dividends.map(d => { + const myShare = positionFractions + ? (Number(toStroopsBigInt(d.per_fraction_amount)) * positionFractions) / 1e7 + : null; + return ( + + + + + {myShare !== null && ( + + )} + + + ); + })} + +
DateTotalPer fractionYour shareStatus
+ {d.distributed_at + ? new Date(d.distributed_at).toLocaleDateString() + : new Date(d.created_at).toLocaleDateString()} + {d.note && ( + · {d.note.slice(0, 20)}{d.note.length > 20 ? '…' : ''} + )} + + {d.total_amount} {d.currency} + + {d.per_fraction_amount} + + {myShare.toFixed(4)} + + + {d.status} + +
+
+ )} + + {/* Originator: create new dividend */} + {isOriginator && originatorId && ( + setDividends(prev => [div, ...prev])} + /> + )} +
+ ); +} diff --git a/invofi/apps/frontend/src/components/securitization/FractionalPositionCard.tsx b/invofi/apps/frontend/src/components/securitization/FractionalPositionCard.tsx new file mode 100644 index 000000000..f26558ea0 --- /dev/null +++ b/invofi/apps/frontend/src/components/securitization/FractionalPositionCard.tsx @@ -0,0 +1,153 @@ +'use client'; + +/** + * FractionalPositionCard + * + * Portfolio card for a single fractional position. + * Displays: + * - Token symbol + name + * - Fractions held and ownership % + * - Current estimated value + * - Dividends earned + * - Link to the source invoice + * - Link to the secondary market to list + */ + +import Link from 'next/link'; +import { + ArrowUpRight, + BadgeDollarSign, + ChartLine, + Layers, + Tag, +} from 'lucide-react'; + +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { FractionalPositionView } from '@/types/securitization'; + +interface FractionalPositionCardProps { + view: FractionalPositionView; + className?: string; +} + +export function FractionalPositionCard({ view, className }: FractionalPositionCardProps) { + const { position, record, currentValue, totalDividendsEarned, ownershipPercent } = view; + + const statusStyles: Record = { + active: 'bg-green-50 text-green-700 border-green-200', + sold_out: 'bg-gray-50 text-gray-500 border-gray-200', + cancelled:'bg-red-50 text-red-700 border-red-200', + }; + + return ( + + + + {/* Header */} +
+
+
+ + + {record?.token_symbol ?? '—'} + +
+

+ {record?.token_name ?? ''} +

+
+ + {record?.status ?? 'active'} + +
+ + {/* Stats grid */} +
+
+
+ + Fractions +
+

+ {position.fraction_count.toLocaleString()} +

+

+ {ownershipPercent.toFixed(2)}% of supply +

+
+ +
+
+ + Est. value +
+

+ {parseFloat(currentValue).toFixed(2)} +

+

+ {position.purchase_currency} +

+
+ +
+
+ + Dividends +
+

+ {parseFloat(totalDividendsEarned).toFixed(4)} +

+

+ {position.purchase_currency} earned +

+
+ +
+ Purchase price +

+ {parseFloat(position.purchase_price_per_fraction).toFixed(4)} +

+

+ {position.purchase_currency} / fraction +

+
+
+ + {/* Invoice link */} + {record?.invoice_id && ( +

+ Invoice:{' '} + + {record.invoice_id} + +

+ )} + + {/* Actions */} +
+ {record?.invoice_id && ( + + )} + +
+
+
+ ); +} diff --git a/invofi/apps/frontend/src/components/securitization/FractionalizationWizard.tsx b/invofi/apps/frontend/src/components/securitization/FractionalizationWizard.tsx new file mode 100644 index 000000000..518e87640 --- /dev/null +++ b/invofi/apps/frontend/src/components/securitization/FractionalizationWizard.tsx @@ -0,0 +1,425 @@ +'use client'; + +/** + * FractionalizationWizard + * + * Three-step flow for invoice owners to split their invoice into N position + * fraction tokens: + * + * Step 1 — Configure: set N, price/fraction, token metadata, description + * Step 2 — Review: summary of economics, confirm before writing + * Step 3 — Done: success state with share link and next actions + * + * The wizard records the fractionalization in Supabase via + * createFractionalization(). The on-chain position token transfer (moving + * POS tokens into a "vault" held by the platform) is outside scope for the + * off-chain-first design — the record itself unlocks the purchase flow. + */ + +import { useCallback, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + ArrowLeft, + ArrowRight, + Check, + Loader2, + Scissors, + Tag, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import { useToast } from '@/components/ui/use-toast'; +import { + fractionalizationSchema, + createFractionalization, + computeTotalCost, + type FractionalizationDraft, +} from '@/lib/securitization'; +import { formatAmount, toStroopsBigInt } from '@/lib/utils'; +import type { FractionalizationRecord } from '@/types/securitization'; +import type { Invoice } from '@/types'; + +// ── Sub-components ──────────────────────────────────────────────────────────── + +interface StepIndicatorProps { + current: number; + total: number; +} + +function StepIndicator({ current, total }: StepIndicatorProps) { + return ( +
+ {Array.from({ length: total }, (_, i) => { + const step = i + 1; + const done = step < current; + const active = step === current; + return ( +
+
+ {done ? : step} +
+ {i < total - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return

{message}

; +} + +// ── Step 1: Configure ───────────────────────────────────────────────────────── + +interface Step1Props { + invoice: Invoice; + onNext: (data: FractionalizationDraft) => void; + defaultValues?: Partial; +} + +function Step1Configure({ invoice, onNext, defaultValues }: Step1Props) { + const invoiceAmountXlm = Number(toStroopsBigInt(invoice.amount)) / 1e7; + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(fractionalizationSchema), + defaultValues: { + totalFractions: defaultValues?.totalFractions ?? 100, + pricePerFraction: defaultValues?.pricePerFraction ?? '', + priceCurrency: defaultValues?.priceCurrency ?? 'USDC', + tokenSymbol: defaultValues?.tokenSymbol ?? `INV-${invoice.id.toUpperCase().slice(-4)}-FRAC`, + tokenName: defaultValues?.tokenName ?? `Invoice ${invoice.id} Fraction`, + description: defaultValues?.description ?? '', + }, + }); + + const totalFractions = watch('totalFractions') || 0; + const pricePerFraction = watch('pricePerFraction') || '0'; + const priceCurrency = watch('priceCurrency'); + + let totalSaleValue = '0'; + try { + totalSaleValue = computeTotalCost(pricePerFraction || '0', totalFractions); + } catch { /* invalid input — ignore */ } + + return ( +
+
+ Fractionalizing invoice {invoice.id} + {' '}· {formatAmount(invoice.amount)} {invoice.currency} +
+ +
+
+ + + +
+ +
+ +
+ + +
+ +
+
+ + {totalFractions > 0 && Number(totalSaleValue) > 0 && ( +
+

+ Total sale value: + {totalSaleValue} {priceCurrency} +

+

+ Each fraction = {(invoiceAmountXlm / totalFractions).toFixed(4)} {invoice.currency} of invoice principal +

+
+ )} + +
+
+ + + +
+
+ + + +
+
+ +
+ +