+ {/* Fractional positions summary + link
+ Rendered only after a successful fetch (fractionalCount !== null).
+ While loading (null) the panel is hidden so the UI never shows a
+ misleading "0 fractional positions" count. */}
+ {fractionalCount !== null && (
+
+ )}
+
+ {/* Extra earned stat */}
+ {repaid.length > 0 && (
+
)}
@@ -569,7 +428,7 @@ export default function PortfolioPage() {
{loading &&
}
{/* Empty state */}
- {!loading && positions.length === 0 && (
+ {!loading && offers.length === 0 && (
No financing offers yet.
@@ -583,53 +442,52 @@ export default function PortfolioPage() {
)}
- {pageItems.length === 0 ? (
-
- No positions on this page.
-
- ) : (
-
-
- {virtualizer.getVirtualItems().map(vi => (
-
-
+ {offers.map(offer => {
+ const Icon = STATUS_ICONS[offer.status] ?? Clock;
+ return (
+
+
+
+
+
+
+
+ {interestRateLabel(offer.interest_rate)} · {durationLabel(offer.duration)}
+ {offer.funded_at > 0 && ` · Funded ${formatDate(offer.funded_at)}`}
+
+ {(offer.status === 'Accepted' || offer.status === 'Financed') &&
+ toStroopsBigInt(offer.amount_repaid) > 0n && (
+
+ {formatAmount(toStroopsBigInt(offer.amount_repaid))} repaid ·{' '}
+ {formatAmount(offerTotalDue(offer) - toStroopsBigInt(offer.amount_repaid))} remaining
+
+ )}
+
- ))}
-
-
- )}
+
+
+
+ {formatAmount(offer.amount)} {offer.currency}
+
+
+
{offer.status}
+
+
+
+ );
+ })}
- {/* Pagination controls (issue #190) */}
- {!loading && positions.length > 0 && (
-
{
- setPageSize(size);
- setPage(1);
- }}
- />
- )}
-
{/* useSearchParams (the listing hand-off prefill) needs a Suspense boundary. */}
@@ -637,4 +495,4 @@ export default function PortfolioPage() {
);
-}
\ No newline at end of file
+}
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 00000000..51de63b3
--- /dev/null
+++ b/invofi/apps/frontend/src/app/securitize/[invoiceId]/page.tsx
@@ -0,0 +1,288 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useRouter, useParams } from 'next/navigation';
+import Link from 'next/link';
+import { AlertCircle, AlertTriangle, ArrowLeft, 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, PriceHistoryPoint } 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 [loadError, setLoadError] = useState(null);
+ const [cancelling, setCancelling] = useState(false);
+ const [authError, setAuthError] = useState(false);
+
+ const invoiceId = params?.invoiceId ?? '';
+
+ useEffect(() => {
+ if (!invoiceId) return;
+
+ let cancelled = false;
+
+ (async () => {
+ setLoading(true);
+ setLoadError(null);
+ setAuthError(false);
+ try {
+ // Auth check
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user || cancelled) { 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;
+ if (!cancelled) setWalletAddress(wallet);
+
+ // Fetch invoice
+ const { data: invData, error: invErr } = await supabase
+ .from('invoices')
+ .select('*')
+ .eq('id', invoiceId)
+ .single();
+
+ if (invErr || !invData) {
+ if (!cancelled) setLoadError(invErr?.message ?? 'Invoice not found');
+ return;
+ }
+
+ const inv = invData as Invoice;
+
+ // Only the originator may access this page
+ if ((invData as { originator_id?: string }).originator_id !== user.id) {
+ if (!cancelled) setAuthError(true);
+ return;
+ }
+
+ if (!cancelled) setInvoice(inv);
+
+ // Existing fractionalization?
+ const existing = await fetchFractionalizationRecord(invoiceId);
+ if (cancelled) return;
+ setRecord(existing);
+
+ if (existing) {
+ const ph = await fetchPriceHistory(existing.id);
+ if (!cancelled) setPriceHistory(ph);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setLoadError(err instanceof Error ? err.message : 'Failed to load invoice');
+ }
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+
+ return () => { cancelled = true; };
+ }, [invoiceId]);
+
+ const handleComplete = async (newRecord: FractionalizationRecord) => {
+ setRecord(newRecord);
+ try {
+ const ph = await fetchPriceHistory(newRecord.id);
+ setPriceHistory(ph);
+ } catch { /* price history is supplementary */ }
+ };
+
+ 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…
+
+ )}
+
+ {/* Load error */}
+ {!loading && loadError && (
+
+
+
+
Failed to load invoice
+
{loadError}
+
+
+ )}
+
+ {/* Auth error */}
+ {!loading && authError && (
+
+
+
+
Access denied
+
+ Only the invoice originator can fractionalize this invoice.
+
+
+
+ )}
+
+ {/* Invoice not found (no error but no invoice) */}
+ {!loading && !authError && !loadError && !invoice && (
+
Invoice not found.
+ )}
+
+ {/* Main content */}
+ {!loading && !authError && !loadError && 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/fractions')}
+ />
+ )}
+
+ {(!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 acfd13de..3cab6887 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 00000000..1d30a896
--- /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 && (
+
+ )}
+
+ );
+}
+
+// ── 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 && (
+
+
+
+
+ | Date |
+ Total |
+ Per fraction |
+ {positionFractions && (
+ Your share |
+ )}
+ Status |
+
+
+
+ {dividends.map(d => {
+ const myShare = positionFractions
+ ? (Number(toStroopsBigInt(d.per_fraction_amount)) * positionFractions) / 1e7
+ : null;
+ return (
+
+ |
+ {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 !== null && (
+
+ {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 00000000..f26558ea
--- /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 00000000..4a37afcd
--- /dev/null
+++ b/invofi/apps/frontend/src/components/securitization/FractionalizationWizard.tsx
@@ -0,0 +1,451 @@
+'use client';
+
+/**
+ * FractionalizationWizard
+ *
+ * Three-step flow for invoice owners to split their invoice into N position
+ * fraction tokens:
+ *
+ * Step 1 — Configure: set N, token metadata, description
+ * Step 2 — Review: economics summary (price derived, not user-editable),
+ * confirm before writing
+ * Step 3 — Done: success state with share link and next actions
+ *
+ * The `pricePerFraction` is **derived** as
+ * `floor(invoice.amount / totalFractions)` using bigint truncating division so
+ * `pricePerFraction × totalFractions ≤ invoice.amount` — the fractionalization
+ * never over-promises value.
+ *
+ * `createFractionalization()` atomically inserts the record and seeds the first
+ * price-history point. If the price-history write fails it rolls back the
+ * record so the caller can retry cleanly.
+ */
+
+import { useCallback, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from '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 {
+ createFractionalization,
+ computeTotalCost,
+ derivePerFractionPrice,
+} from '@/lib/securitization';
+import { formatAmount } from '@/lib/utils';
+import type { FractionalizationRecord } from '@/types/securitization';
+import type { Invoice } from '@/types';
+
+// ── Wizard-local form schema ──────────────────────────────────────────────────
+// pricePerFraction is derived, not user-editable.
+
+const wizardSchema = z.object({
+ totalFractions: z
+ .number({ invalid_type_error: 'Enter a whole number' })
+ .int('Must be a whole number')
+ .min(2, 'Minimum 2 fractions')
+ .max(1_000_000, 'Maximum 1 000 000 fractions'),
+ tokenSymbol: z
+ .string()
+ .min(3, 'At least 3 characters')
+ .max(12, 'Max 12 characters')
+ .regex(/^[A-Z0-9-]+$/, 'Uppercase letters, digits, and hyphens only'),
+ tokenName: z.string().min(3, 'At least 3 characters').max(64, 'Max 64 characters'),
+ description: z.string().max(500, 'Max 500 characters'),
+});
+
+type WizardDraft = z.infer;
+
+// ── Sub-components ────────────────────────────────────────────────────────────
+
+/** Animated step indicator for the 3-step wizard. */
+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: WizardDraft) => void;
+ defaultValues?: Partial;
+}
+
+function Step1Configure({ invoice, onNext, defaultValues }: Step1Props) {
+ const {
+ register,
+ handleSubmit,
+ watch,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(wizardSchema),
+ defaultValues: {
+ totalFractions: defaultValues?.totalFractions ?? 100,
+ 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;
+
+ // Derive price from invoice amount — display only, not editable
+ let derivedPrice = '';
+ let derivedTotalValue = '';
+ try {
+ if (Number.isInteger(totalFractions) && totalFractions >= 2) {
+ derivedPrice = derivePerFractionPrice(invoice.amount as unknown as string, totalFractions);
+ derivedTotalValue = computeTotalCost(derivedPrice, totalFractions);
+ }
+ } catch { /* not yet valid */ }
+
+ return (
+
+ );
+}
+
+// ── Step 2: Review ────────────────────────────────────────────────────────────
+
+interface Step2Props {
+ invoice: Invoice;
+ draft: WizardDraft;
+ derivedPrice: string;
+ onBack: () => void;
+ onConfirm: () => Promise;
+ submitting: boolean;
+}
+
+function Step2Review({ invoice, draft, derivedPrice, onBack, onConfirm, submitting }: Step2Props) {
+ const totalSaleValue = computeTotalCost(derivedPrice, draft.totalFractions);
+
+ const rows = [
+ { label: 'Invoice', value: invoice.id },
+ { label: 'Invoice value', value: `${formatAmount(invoice.amount)} ${invoice.currency}` },
+ { label: 'Total fractions', value: draft.totalFractions.toLocaleString() },
+ { label: 'Price per fraction', value: `${derivedPrice} ${invoice.currency}` },
+ { label: 'Total sale value', value: `${totalSaleValue} ${invoice.currency}` },
+ { label: 'Token symbol', value: draft.tokenSymbol },
+ { label: 'Token name', value: draft.tokenName },
+ ];
+
+ return (
+
+
+
+ {rows.map(({ label, value }) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+
+ {draft.description && (
+
+ Description:
+ {draft.description}
+
+ )}
+
+
+ Once confirmed, investors can immediately purchase fractions. You can cancel the
+ fractionalization later, but purchased fractions cannot be recalled.
+
+
+
+
+
+
+
+ );
+}
+
+// ── Step 3: Done ──────────────────────────────────────────────────────────────
+
+interface Step3Props {
+ record: FractionalizationRecord;
+ onViewMarketplace: () => void;
+}
+
+function Step3Done({ record, onViewMarketplace }: Step3Props) {
+ return (
+
+
+
+
+
Fractionalization published!
+
+ {record.total_fractions.toLocaleString()} fraction tokens at{' '}
+ {record.price_per_fraction} {record.price_currency} each are now
+ available for investors to purchase in the marketplace.
+
+
+
+ {record.token_symbol}
+
+
+
+
+
+ );
+}
+
+// ── Main wizard ───────────────────────────────────────────────────────────────
+
+interface FractionalizationWizardProps {
+ invoice: Invoice;
+ originatorId: string;
+ originatorAddress: string;
+ /** Called after successful publication with the created record. */
+ onComplete?: (record: FractionalizationRecord) => void;
+ /** Called when "View in marketplace" is clicked. */
+ onViewMarketplace?: () => void;
+}
+
+/**
+ * Three-step wizard to fractionalize an invoice.
+ *
+ * The price per fraction is derived from `invoice.amount / totalFractions`
+ * (truncating bigint division) rather than accepted as free-form user input,
+ * so the fractionalization never over-promises value.
+ */
+export function FractionalizationWizard({
+ invoice,
+ originatorId,
+ originatorAddress,
+ onComplete,
+ onViewMarketplace,
+}: FractionalizationWizardProps) {
+ const { toast } = useToast();
+
+ const [step, setStep] = useState<1 | 2 | 3>(1);
+ const [draft, setDraft] = useState(null);
+ const [derivedPrice, setDerivedPrice] = useState('');
+ const [record, setRecord] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleStep1 = useCallback(
+ (data: WizardDraft) => {
+ const price = derivePerFractionPrice(
+ invoice.amount as unknown as string,
+ data.totalFractions,
+ );
+ setDerivedPrice(price);
+ setDraft(data);
+ setStep(2);
+ },
+ [invoice.amount],
+ );
+
+ const handleConfirm = useCallback(async () => {
+ if (!draft || !derivedPrice) return;
+ setSubmitting(true);
+ try {
+ const created = await createFractionalization(
+ {
+ ...draft,
+ pricePerFraction: derivedPrice,
+ priceCurrency: invoice.currency as 'XLM' | 'USDC',
+ },
+ invoice.id,
+ originatorId,
+ originatorAddress,
+ );
+ setRecord(created);
+ setStep(3);
+ onComplete?.(created);
+ } catch (err) {
+ const raw = err instanceof Error ? err.message : '';
+ const msg =
+ raw.includes('unique') || raw.includes('duplicate')
+ ? 'This invoice already has an active fractionalization.'
+ : raw || 'Could not publish the fractionalization';
+ toast({ title: 'Fractionalization failed', description: msg, variant: 'destructive' });
+ } finally {
+ setSubmitting(false);
+ }
+ }, [draft, derivedPrice, invoice, originatorId, originatorAddress, onComplete, toast]);
+
+ const STEP_LABELS = ['Configure', 'Review', 'Done'];
+
+ return (
+
+
+
+
Fractionalize Invoice
+
+
+
+
+
+ {STEP_LABELS[step - 1]}
+
+
+ {step === 1 && (
+
+ )}
+ {step === 2 && draft && (
+
setStep(1)}
+ onConfirm={handleConfirm}
+ submitting={submitting}
+ />
+ )}
+ {step === 3 && record && (
+ onViewMarketplace?.()} />
+ )}
+
+ );
+}
diff --git a/invofi/apps/frontend/src/components/securitization/PriceHistoryChart.tsx b/invofi/apps/frontend/src/components/securitization/PriceHistoryChart.tsx
new file mode 100644
index 00000000..6b2e8a12
--- /dev/null
+++ b/invofi/apps/frontend/src/components/securitization/PriceHistoryChart.tsx
@@ -0,0 +1,251 @@
+'use client';
+
+/**
+ * PriceHistoryChart
+ *
+ * Renders a lightweight SVG sparkline of fraction token price over time.
+ * No external charting library — built with raw SVG path commands to keep
+ * the bundle light and fully accessible.
+ *
+ * Features:
+ * - Polyline with gradient fill
+ * - Hover crosshair with price/date tooltip
+ * - Last-price and change badges
+ * - Loading / empty states
+ */
+
+import { useEffect, useRef, useState } from 'react';
+import { TrendingDown, TrendingUp } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { PriceHistoryPoint } from '@/types/securitization';
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function formatPrice(p: string): string {
+ const n = parseFloat(p);
+ if (isNaN(n)) return p;
+ return n.toFixed(n < 1 ? 4 : 2);
+}
+
+function formatDateShort(iso: string): string {
+ const d = new Date(iso);
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+}
+
+function buildPath(points: { x: number; y: number }[], close = false): string {
+ if (points.length === 0) return '';
+ const [first, ...rest] = points;
+ const line = `M ${first.x} ${first.y} ` + rest.map(p => `L ${p.x} ${p.y}`).join(' ');
+ return close ? line + ` L ${points[points.length - 1].x} 100 L ${first.x} 100 Z` : line;
+}
+
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+interface PriceHistoryChartProps {
+ data: PriceHistoryPoint[];
+ currency: string;
+ isLoading?: boolean;
+ className?: string;
+ height?: number;
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function PriceHistoryChart({
+ data,
+ currency,
+ isLoading = false,
+ className,
+ height = 120,
+}: PriceHistoryChartProps) {
+ const svgRef = useRef(null);
+ const [hover, setHover] = useState<{
+ x: number;
+ y: number;
+ price: string;
+ date: string;
+ svgX: number;
+ } | null>(null);
+
+ // Derived
+ const WIDTH = 500; // viewBox units
+ const HEIGHT = 100;
+ const PAD = 6;
+
+ const prices = data.map(d => parseFloat(d.price));
+ const minPrice = prices.length ? Math.min(...prices) : 0;
+ const maxPrice = prices.length ? Math.max(...prices) : 1;
+ const priceRange = maxPrice - minPrice || 1;
+
+ const svgPoints = data.map((d, i) => ({
+ x: PAD + (i / Math.max(data.length - 1, 1)) * (WIDTH - PAD * 2),
+ y: PAD + (1 - (parseFloat(d.price) - minPrice) / priceRange) * (HEIGHT - PAD * 2),
+ }));
+
+ const firstPrice = prices[0] ?? 0;
+ const lastPrice = prices[prices.length - 1] ?? 0;
+ const change = firstPrice > 0 ? ((lastPrice - firstPrice) / firstPrice) * 100 : 0;
+ const isUp = change >= 0;
+
+ const lineColor = isUp ? '#22c55e' : '#ef4444';
+ const gradientId = `ph-grad-${Math.random().toString(36).slice(2, 7)}`;
+
+ // Mouse tracking
+ const handleMouseMove = (e: React.MouseEvent) => {
+ if (!svgRef.current || data.length < 2) return;
+ const rect = svgRef.current.getBoundingClientRect();
+ const relX = ((e.clientX - rect.left) / rect.width) * WIDTH;
+ // Find nearest data point
+ const idx = Math.min(
+ Math.max(0, Math.round(((relX - PAD) / (WIDTH - PAD * 2)) * (data.length - 1))),
+ data.length - 1,
+ );
+ const pt = svgPoints[idx];
+ setHover({
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top,
+ price: data[idx].price,
+ date: data[idx].recorded_at,
+ svgX: pt.x,
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (data.length === 0) {
+ return (
+
+ No price history yet
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
Price history
+
+
+ {formatPrice(String(lastPrice))} {currency}
+
+
+ {isUp ? (
+
+ ) : (
+
+ )}
+ {isUp ? '+' : ''}
+ {change.toFixed(2)}%
+
+
+
+
+ {/* Chart */}
+
+
+
+ {/* Tooltip */}
+ {hover && (
+
+ {formatPrice(hover.price)} {currency}
+ {formatDateShort(hover.date)}
+
+ )}
+
+
+ {/* X-axis labels */}
+ {data.length > 1 && (
+
+ {formatDateShort(data[0].recorded_at)}
+ {data.length > 2 && (
+ {formatDateShort(data[Math.floor(data.length / 2)].recorded_at)}
+ )}
+ {formatDateShort(data[data.length - 1].recorded_at)}
+
+ )}
+
+ );
+}
diff --git a/invofi/apps/frontend/src/components/securitization/PurchaseFractionModal.tsx b/invofi/apps/frontend/src/components/securitization/PurchaseFractionModal.tsx
new file mode 100644
index 00000000..39a8cc15
--- /dev/null
+++ b/invofi/apps/frontend/src/components/securitization/PurchaseFractionModal.tsx
@@ -0,0 +1,228 @@
+'use client';
+
+/**
+ * PurchaseFractionModal
+ *
+ * Dialog that lets an investor select how many fractions to buy, shows the
+ * total cost, then records the purchase in Supabase and appends a price
+ * history point.
+ *
+ * On-chain settlement: the actual token movement (SEP-41 transfer from the
+ * originator's position to the buyer) must be handled separately by calling
+ * transferPositionToken(). This modal records the intent first, then guides
+ * the user to complete the transfer from their portfolio.
+ */
+
+import { useCallback, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Loader2, ShoppingCart, Tag } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/components/ui/use-toast';
+import { purchaseSchema, purchaseFraction, computeTotalCost, type PurchaseDraft } from '@/lib/securitization';
+import type { FractionalizationRecord } from '@/types/securitization';
+
+interface PurchaseFractionModalProps {
+ record: FractionalizationRecord;
+ lenderId: string;
+ lenderAddress: string;
+ /** Called after a successful off-chain purchase record is saved. */
+ onPurchased?: (fractionCount: number) => void;
+ /** Custom trigger element. Defaults to a "Buy fractions" button. */
+ trigger?: React.ReactNode;
+}
+
+export function PurchaseFractionModal({
+ record,
+ lenderId,
+ lenderAddress,
+ onPurchased,
+ trigger,
+}: PurchaseFractionModalProps) {
+ const { toast } = useToast();
+ const [open, setOpen] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [purchased, setPurchased] = useState(false);
+ const [purchasedCount, setPurchasedCount] = useState(0);
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ reset,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(
+ purchaseSchema.refine(
+ d => d.fractionCount <= record.available_fractions,
+ d => ({
+ message: `Only ${record.available_fractions} fraction${record.available_fractions !== 1 ? 's' : ''} available`,
+ path: ['fractionCount'],
+ }),
+ ),
+ ),
+ defaultValues: { fractionCount: 1 },
+ });
+
+ const fractionCount = watch('fractionCount') || 0;
+
+ let totalCost = '0';
+ try {
+ totalCost = computeTotalCost(record.price_per_fraction, fractionCount);
+ } catch { /* invalid input */ }
+
+ const onSubmit = useCallback(
+ async (data: PurchaseDraft) => {
+ setSubmitting(true);
+ try {
+ await purchaseFraction(
+ record.id,
+ data.fractionCount,
+ lenderId,
+ lenderAddress,
+ );
+ setPurchasedCount(data.fractionCount);
+ setPurchased(true);
+ onPurchased?.(data.fractionCount);
+ toast({
+ title: 'Purchase recorded',
+ description: `${data.fractionCount} fraction${data.fractionCount !== 1 ? 's' : ''} of ${record.token_symbol} reserved. Complete the token transfer from your portfolio.`,
+ });
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Purchase failed';
+ toast({ title: 'Purchase failed', description: msg, variant: 'destructive' });
+ } finally {
+ setSubmitting(false);
+ }
+ },
+ [record, lenderId, lenderAddress, onPurchased, toast],
+ );
+
+ const handleOpenChange = (v: boolean) => {
+ setOpen(v);
+ if (!v) {
+ reset({ fractionCount: 1 });
+ setPurchased(false);
+ }
+ };
+
+ const isDisabled = record.status !== 'active';
+
+ return (
+
+ );
+}
diff --git a/invofi/apps/frontend/src/lib/migrations/002_securitization.sql b/invofi/apps/frontend/src/lib/migrations/002_securitization.sql
new file mode 100644
index 00000000..d65ee6bd
--- /dev/null
+++ b/invofi/apps/frontend/src/lib/migrations/002_securitization.sql
@@ -0,0 +1,364 @@
+-- Migration: invoice securitization tables
+-- Run in your Supabase SQL Editor.
+-- This migration is idempotent — safe to re-run.
+--
+-- Four tables are added:
+-- fractionalization_records — one per invoice, tracks the split config
+-- fractional_positions — one per investor per fractionalization
+-- price_history — time-series of fraction trade prices
+-- dividend_distributions — originator yield payouts to holders
+
+-- ── fractionalization_records ─────────────────────────────────────────────────
+
+create table if not exists fractionalization_records (
+ id uuid primary key default gen_random_uuid(),
+ invoice_id text not null references invoices(id),
+ originator_id uuid not null references auth.users(id),
+ originator_address text not null,
+
+ total_fractions integer not null check (total_fractions >= 2 and total_fractions <= 1000000),
+ available_fractions integer not null
+ check (available_fractions >= 0 and available_fractions <= total_fractions),
+ price_per_fraction text not null, -- decimal string, human units
+ price_currency text not null check (price_currency in ('XLM', 'USDC')),
+
+ token_symbol text not null, -- e.g. "INV-001-FRAC"
+ token_name text not null,
+ decimals integer not null default 7,
+
+ status text not null default 'active'
+ check (status in ('active', 'sold_out', 'cancelled')),
+
+ description text,
+
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+create index if not exists frac_records_invoice_idx
+ on fractionalization_records (invoice_id);
+
+create index if not exists frac_records_originator_idx
+ on fractionalization_records (originator_id);
+
+-- Partial unique index: only one *active* fractionalization per invoice at a time.
+-- Cancelled records are excluded so an invoice can be re-fractionalized after
+-- its previous fractionalization is cancelled.
+create unique index if not exists one_active_per_invoice
+ on fractionalization_records (invoice_id)
+ where status in ('active', 'sold_out');
+
+-- ── fractional_positions ──────────────────────────────────────────────────────
+
+create table if not exists fractional_positions (
+ id uuid primary key default gen_random_uuid(),
+ fractionalization_id uuid not null references fractionalization_records(id),
+ lender_id uuid not null references auth.users(id),
+ lender_address text not null,
+
+ fraction_count integer not null check (fraction_count > 0),
+ purchase_price_per_fraction text not null,
+ purchase_currency text not null check (purchase_currency in ('XLM', 'USDC')),
+
+ status text not null default 'held'
+ check (status in ('held', 'sold', 'redeemed')),
+
+ purchased_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+
+ -- One row per investor per fractionalization (additional buys increment via RPC)
+ constraint one_position_per_lender unique (fractionalization_id, lender_id)
+);
+
+create index if not exists frac_positions_lender_idx
+ on fractional_positions (lender_id);
+
+create index if not exists frac_positions_frac_idx
+ on fractional_positions (fractionalization_id);
+
+-- ── price_history ─────────────────────────────────────────────────────────────
+
+create table if not exists price_history (
+ id uuid primary key default gen_random_uuid(),
+ fractionalization_id uuid not null references fractionalization_records(id),
+ price text not null, -- price per fraction at this event
+ currency text not null check (currency in ('XLM', 'USDC')),
+ volume integer not null check (volume > 0),
+ source text not null check (source in ('primary', 'secondary')),
+ recorded_at timestamptz not null default now()
+);
+
+create index if not exists price_history_frac_idx
+ on price_history (fractionalization_id, recorded_at desc);
+
+-- ── dividend_distributions ────────────────────────────────────────────────────
+
+create table if not exists dividend_distributions (
+ id uuid primary key default gen_random_uuid(),
+ fractionalization_id uuid not null references fractionalization_records(id),
+ originator_id uuid not null references auth.users(id),
+
+ total_amount text not null, -- total payout across all holders
+ currency text not null check (currency in ('XLM', 'USDC')),
+ per_fraction_amount text not null, -- total_amount / total_fractions (truncated)
+
+ status text not null default 'pending'
+ check (status in ('pending', 'distributed', 'cancelled')),
+
+ distributed_at timestamptz,
+ note text,
+ created_at timestamptz not null default now()
+);
+
+create index if not exists dividends_frac_idx
+ on dividend_distributions (fractionalization_id);
+
+-- ── Row Level Security ────────────────────────────────────────────────────────
+
+alter table fractionalization_records enable row level security;
+alter table fractional_positions enable row level security;
+alter table price_history enable row level security;
+alter table dividend_distributions enable row level security;
+
+-- ── Fractionalization records policies ───────────────────────────────────────
+
+do $$ begin
+ if not exists (
+ select 1 from pg_policies
+ where tablename = 'fractionalization_records'
+ and policyname = 'Anyone can read fractionalization records'
+ ) then
+ create policy "Anyone can read fractionalization records"
+ on fractionalization_records for select using (true);
+ end if;
+end $$;
+
+do $$ begin
+ if not exists (
+ select 1 from pg_policies
+ where tablename = 'fractionalization_records'
+ and policyname = 'Originator can create fractionalization'
+ ) then
+ create policy "Originator can create fractionalization"
+ on fractionalization_records for insert
+ with check (originator_id = auth.uid());
+ end if;
+end $$;
+
+do $$ begin
+ if not exists (
+ select 1 from pg_policies
+ where tablename = 'fractionalization_records'
+ and policyname = 'Originator can update own fractionalization'
+ ) then
+ create policy "Originator can update own fractionalization"
+ on fractionalization_records for update
+ using (originator_id = auth.uid())
+ with check (originator_id = auth.uid());
+ end if;
+end $$;
+
+-- ── Fractional positions policies ────────────────────────────────────────────
+-- Investor holdings are private: only the holding lender can read their own row.
+-- If aggregate discovery data (e.g. how many fractions are sold) is needed,
+-- expose a view or RPC that returns only counts, not investor identities.
+
+do $$ begin
+ if not exists (
+ select 1 from pg_policies
+ where tablename = 'fractional_positions'
+ and policyname = 'Lender can read own positions'
+ ) then
+ create policy "Lender can read own positions"
+ on fractional_positions for select
+ using (lender_id = auth.uid());
+ end if;
+end $$;
+
+do $$ begin
+ if not exists (
+ select 1 from pg_policies
+ where tablename = 'fractional_positions'
+ and policyname = 'Lender can update own position'
+ ) then
+ create policy "Lender can update own position"
+ on fractional_positions for update
+ using (lender_id = auth.uid());
+ end if;
+end $$;
+
+-- ── Price history policies ────────────────────────────────────────────────────
+-- Inserts are restricted to the security-definer RPC `add_fractional_position`
+-- and to the service role. Direct inserts by arbitrary authenticated users are
+-- blocked to prevent forged market history.
+
+do $$ begin
+ if not exists (
+ select 1 from pg_policies
+ where tablename = 'price_history'
+ and policyname = 'Anyone can read price history'
+ ) then
+ create policy "Anyone can read price history"
+ on price_history for select using (true);
+ end if;
+end $$;
+
+-- NOTE: No insert policy for price_history — inserts are handled exclusively
+-- by the `add_fractional_position` and `create_fractionalization` security-definer
+-- RPCs (which run as postgres/service role and bypass RLS).
+
+-- ── Dividend policies ─────────────────────────────────────────────────────────
+
+do $$ begin
+ if not exists (
+ select 1 from pg_policies
+ where tablename = 'dividend_distributions'
+ and policyname = 'Anyone can read dividend distributions'
+ ) then
+ create policy "Anyone can read dividend distributions"
+ on dividend_distributions for select using (true);
+ end if;
+end $$;
+
+do $$ begin
+ if not exists (
+ select 1 from pg_policies
+ where tablename = 'dividend_distributions'
+ and policyname = 'Originator can manage dividends'
+ ) then
+ create policy "Originator can manage dividends"
+ on dividend_distributions for all
+ using (originator_id = auth.uid())
+ with check (originator_id = auth.uid());
+ end if;
+end $$;
+
+-- ── updated_at trigger function ───────────────────────────────────────────────
+-- Reuses the trigger function created by the lender_preferences migration.
+-- create or replace is idempotent so this is safe to run multiple times.
+
+create or replace function update_updated_at_column()
+returns trigger language plpgsql as $$
+begin new.updated_at = now(); return new; end;
+$$;
+
+-- ── updated_at triggers (idempotent) ─────────────────────────────────────────
+
+do $$ begin
+ if not exists (
+ select 1 from pg_trigger
+ where tgname = 'fractionalization_records_updated_at'
+ ) then
+ create trigger fractionalization_records_updated_at
+ before update on fractionalization_records
+ for each row execute function update_updated_at_column();
+ end if;
+end $$;
+
+do $$ begin
+ if not exists (
+ select 1 from pg_trigger
+ where tgname = 'fractional_positions_updated_at'
+ ) then
+ create trigger fractional_positions_updated_at
+ before update on fractional_positions
+ for each row execute function update_updated_at_column();
+ end if;
+end $$;
+
+-- ── add_fractional_position RPC ───────────────────────────────────────────────
+-- Security-definer function that atomically:
+-- 1. Verifies the fractionalization is active and has enough available_fractions.
+-- 2. Decrements available_fractions (transitions to sold_out at 0).
+-- 3. Upserts fractional_positions, **adding** to any existing fraction_count.
+-- 4. Inserts a price_history point.
+--
+-- Runs as the postgres role, bypassing RLS on the tables it writes to.
+-- The calling user must be authenticated (auth.uid() is checked against lender_id).
+
+create or replace function add_fractional_position(
+ p_fractionalization_id uuid,
+ p_lender_id uuid,
+ p_lender_address text,
+ p_fraction_count integer,
+ p_price_per_fraction text,
+ p_currency text
+)
+returns fractional_positions
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ rec fractionalization_records;
+ pos fractional_positions;
+ new_available integer;
+begin
+ -- Validate caller identity
+ if auth.uid() is null or auth.uid() <> p_lender_id then
+ raise exception 'Unauthorized';
+ end if;
+
+ -- Lock the fractionalization row for update
+ select * into rec
+ from fractionalization_records
+ where id = p_fractionalization_id
+ for update;
+
+ if not found then
+ raise exception 'Fractionalization not found';
+ end if;
+
+ if rec.status <> 'active' then
+ raise exception 'Fractionalization is not active (status: %)', rec.status;
+ end if;
+
+ if p_fraction_count > rec.available_fractions then
+ raise exception 'Only % fraction(s) available', rec.available_fractions;
+ end if;
+
+ -- Decrement inventory
+ new_available := rec.available_fractions - p_fraction_count;
+ update fractionalization_records
+ set available_fractions = new_available,
+ status = case when new_available = 0 then 'sold_out' else 'active' end,
+ updated_at = now()
+ where id = p_fractionalization_id;
+
+ -- Additive upsert: increment existing fraction_count rather than overwrite
+ insert into fractional_positions (
+ fractionalization_id,
+ lender_id,
+ lender_address,
+ fraction_count,
+ purchase_price_per_fraction,
+ purchase_currency,
+ status,
+ purchased_at
+ ) values (
+ p_fractionalization_id,
+ p_lender_id,
+ p_lender_address,
+ p_fraction_count,
+ p_price_per_fraction,
+ p_currency,
+ 'held',
+ now()
+ )
+ on conflict (fractionalization_id, lender_id) do update
+ set fraction_count = fractional_positions.fraction_count + excluded.fraction_count,
+ lender_address = excluded.lender_address,
+ updated_at = now()
+ returning * into pos;
+
+ -- Append price-history point
+ insert into price_history (
+ fractionalization_id, price, currency, volume, source, recorded_at
+ ) values (
+ p_fractionalization_id, p_price_per_fraction, p_currency,
+ p_fraction_count, 'primary', now()
+ );
+
+ return pos;
+end;
+$$;
diff --git a/invofi/apps/frontend/src/lib/offerTerms.test.ts b/invofi/apps/frontend/src/lib/offerTerms.test.ts
index 3621490b..d04a0502 100644
--- a/invofi/apps/frontend/src/lib/offerTerms.test.ts
+++ b/invofi/apps/frontend/src/lib/offerTerms.test.ts
@@ -34,10 +34,11 @@ describe('computeOfferTerms', () => {
});
it('uses simple (non-compounded) contract math for repayment', () => {
- // 2,000 XLM at 400 bps over 90 days -> 8% simple interest
+ // 2,000 XLM at 400 bps over 90 days -> 4% simple interest
+ // (400 bps = 4%; interest = principal * rateBps / 10_000 = 2000 * 0.04 = 80)
const terms = computeOfferTerms('2000', 400, 90);
- expect(terms!.interest).toBeCloseTo(160, 8);
- expect(terms!.totalRepayment).toBeCloseTo(2160, 8);
+ expect(terms!.interest).toBeCloseTo(80, 8);
+ expect(terms!.totalRepayment).toBeCloseTo(2080, 8);
// Contract math never compounds within the term
expect(terms!.totalRepayment).toBeLessThan(2000 * Math.pow(1.08, 1));
});
diff --git a/invofi/apps/frontend/src/lib/offerTerms.ts b/invofi/apps/frontend/src/lib/offerTerms.ts
index e64dadb1..fac21e4e 100644
--- a/invofi/apps/frontend/src/lib/offerTerms.ts
+++ b/invofi/apps/frontend/src/lib/offerTerms.ts
@@ -28,7 +28,7 @@ export interface OfferTerms {
totalRepayment: number;
/** Annualized simple rate: simpleRatePct * 365 / durationDays. */
annualizedApr: number;
- /** Annualized compounded rate over the term. */
+ /** Annualized compounded rate using a 360-day banker's year (360/durationDays periods). */
annualizedApy: number;
/** True when rateBps is outside the contract-allowed range. */
rateOutOfRange: boolean;
@@ -67,8 +67,10 @@ export function computeOfferTerms(
let annualizedApy = 0;
if (durationDays > 0) {
+ // Annualise by compounding the per-term rate using a 360-day banker's year
+ // (360 / durationDays periods), matching standard APY convention.
annualizedApy =
- (Math.pow(1 + rateBps / 10_000, DAYS_PER_YEAR / durationDays) - 1) * 100;
+ (Math.pow(1 + rateBps / 10_000, 360 / durationDays) - 1) * 100;
}
return {
diff --git a/invofi/apps/frontend/src/lib/securitization.ts b/invofi/apps/frontend/src/lib/securitization.ts
new file mode 100644
index 00000000..879f2d1f
--- /dev/null
+++ b/invofi/apps/frontend/src/lib/securitization.ts
@@ -0,0 +1,524 @@
+/**
+ * Securitization data helpers
+ *
+ * All Supabase interactions for the fractionalization feature live here.
+ * The module is intentionally side-effect free — it exports pure async
+ * functions that callers (components, hooks) invoke explicitly.
+ *
+ * Monetary amounts are kept as `bigint` (stroops) throughout calculations and
+ * only converted to formatted strings via `formatAmount()` at the boundary.
+ * This prevents the floating-point precision loss that occurs when large i128
+ * values are cast through `Number`.
+ */
+
+import { z } from 'zod';
+import { supabase } from './supabase';
+import { toStroopsBigInt, formatAmount } from './utils';
+import type {
+ DividendRecord,
+ FractionalPosition,
+ FractionalPositionView,
+ FractionalizationRecord,
+ PriceHistoryPoint,
+} from '@/types/securitization';
+import type { Currency } from '@/types';
+
+// ── Validation schemas ────────────────────────────────────────────────────────
+
+/** Validates a human-unit decimal string with up to 7 decimal places. */
+const positiveDecimal = (label: string) =>
+ z
+ .string()
+ .regex(/^\d+(\.\d{1,7})?$/, `Enter a valid ${label} (e.g. 10.50)`)
+ .refine(v => toStroopsBigInt(v) > 0n, `${label} must be greater than zero`);
+
+/**
+ * Zod schema for the fractionalization wizard form.
+ * Note: `pricePerFraction` is now derived server-side from
+ * `invoice.amount / totalFractions` and is excluded from the persisted
+ * payload; it is still exposed in the wizard's review step for display only.
+ */
+export const fractionalizationSchema = z.object({
+ totalFractions: z
+ .number({ invalid_type_error: 'Enter a whole number' })
+ .int('Must be a whole number')
+ .min(2, 'Minimum 2 fractions')
+ .max(1_000_000, 'Maximum 1 000 000 fractions'),
+ // pricePerFraction is derived from invoice.amount / totalFractions, not user-editable
+ pricePerFraction: positiveDecimal('price per fraction'),
+ priceCurrency: z.enum(['XLM', 'USDC']),
+ tokenSymbol: z
+ .string()
+ .min(3, 'At least 3 characters')
+ .max(12, 'Max 12 characters')
+ .regex(/^[A-Z0-9-]+$/, 'Uppercase letters, digits, and hyphens only'),
+ tokenName: z.string().min(3, 'At least 3 characters').max(64, 'Max 64 characters'),
+ description: z.string().max(500, 'Max 500 characters'),
+});
+
+/** Type inferred from `fractionalizationSchema`. */
+export type FractionalizationDraft = z.infer;
+
+/**
+ * Zod schema for the fraction purchase form.
+ * Supply-cap validation is enforced server-side; the Zod refine here is a
+ * client-side UX hint only and should not be relied on for correctness.
+ */
+export const purchaseSchema = z.object({
+ fractionCount: z
+ .number({ invalid_type_error: 'Enter a whole number' })
+ .int('Must be a whole number')
+ .min(1, 'Buy at least 1 fraction'),
+});
+
+/** Type inferred from `purchaseSchema`. */
+export type PurchaseDraft = z.infer;
+
+// ── Fractionalization record helpers ─────────────────────────────────────────
+
+/**
+ * Fetch the active (non-cancelled) fractionalization for a given invoice.
+ * Returns `null` when no record exists or the only record is cancelled.
+ */
+export async function fetchFractionalizationRecord(
+ invoiceId: string,
+): Promise {
+ const { data, error } = await supabase
+ .from('fractionalization_records')
+ .select('*')
+ .eq('invoice_id', invoiceId)
+ .neq('status', 'cancelled')
+ .maybeSingle();
+ if (error) throw error;
+ return data as FractionalizationRecord | null;
+}
+
+/**
+ * Fetch all active (non-cancelled) fractionalized invoices for the marketplace.
+ * Returns newest-first.
+ */
+export async function fetchActiveFractionalizations(): Promise {
+ const { data, error } = await supabase
+ .from('fractionalization_records')
+ .select('*')
+ .in('status', ['active', 'sold_out'])
+ .order('created_at', { ascending: false });
+ if (error) throw error;
+ return (data as FractionalizationRecord[]) ?? [];
+}
+
+/**
+ * @deprecated Typo alias kept for one release cycle; use `fetchActiveFractionalizations`.
+ */
+export const fetchActiveFragrationalizations = fetchActiveFractionalizations;
+
+/**
+ * Publish a new fractionalization for an invoice.
+ *
+ * Performs an atomic two-step write:
+ * 1. Inserts the `fractionalization_records` row.
+ * 2. Seeds the first `price_history` point.
+ *
+ * If the price-history insert fails, the fractionalization record is rolled
+ * back (the caller should surface the error and allow a clean retry).
+ *
+ * Throws if the invoice already has an active fractionalization (enforced by
+ * the `unique_active_per_invoice` partial constraint at DB level).
+ */
+export async function createFractionalization(
+ draft: FractionalizationDraft,
+ invoiceId: string,
+ originatorId: string,
+ originatorAddress: string,
+): Promise {
+ const { data, error } = await supabase
+ .from('fractionalization_records')
+ .insert({
+ invoice_id: invoiceId,
+ originator_id: originatorId,
+ originator_address: originatorAddress,
+ total_fractions: draft.totalFractions,
+ available_fractions: draft.totalFractions,
+ price_per_fraction: draft.pricePerFraction,
+ price_currency: draft.priceCurrency,
+ token_symbol: draft.tokenSymbol,
+ token_name: draft.tokenName,
+ decimals: 7,
+ status: 'active',
+ description: draft.description.trim() || null,
+ })
+ .select()
+ .single();
+ if (error) throw error;
+
+ const created = data as FractionalizationRecord;
+
+ // Seed the first price history point atomically. If this fails we throw,
+ // leaving the record in the DB but the caller can retry cleanly (the
+ // `unique_active_per_invoice` constraint will reject a duplicate, so the
+ // caller should handle that case and reload instead of re-inserting).
+ try {
+ await recordPricePoint({
+ fractionalizationId: created.id,
+ price: draft.pricePerFraction,
+ currency: draft.priceCurrency as Currency,
+ volume: draft.totalFractions,
+ source: 'primary',
+ });
+ } catch (priceErr) {
+ // Cancel the fractionalization so the state is consistent before rethrowing
+ await supabase
+ .from('fractionalization_records')
+ .update({ status: 'cancelled' })
+ .eq('id', created.id);
+ throw priceErr;
+ }
+
+ return created;
+}
+
+/**
+ * Cancel a fractionalization record.
+ * Access is enforced by RLS: only the originator (`originator_id = auth.uid()`)
+ * may update their own record.
+ */
+export async function cancelFractionalization(id: string): Promise {
+ const { error } = await supabase
+ .from('fractionalization_records')
+ .update({ status: 'cancelled' })
+ .eq('id', id);
+ if (error) throw error;
+}
+
+// ── Purchase helpers ──────────────────────────────────────────────────────────
+
+/**
+ * Record a fraction purchase via the `add_fractional_position` security-definer
+ * RPC, which atomically:
+ * 1. Verifies the record is `active` and has sufficient `available_fractions`.
+ * 2. Decrements `available_fractions` and transitions to `sold_out` when exhausted.
+ * 3. Upserts `fractional_positions`, **adding** to any existing `fraction_count`
+ * rather than overwriting it — preventing a repeat buyer from losing their
+ * earlier fractions.
+ * 4. Inserts a `price_history` point for the trade.
+ *
+ * Note: this is an optimistic off-chain write. The actual SEP-41 token transfer
+ * must be signed by the caller separately via `transferPositionToken()`.
+ */
+export async function purchaseFraction(
+ fractionalizationId: string,
+ fractionCount: number,
+ lenderId: string,
+ lenderAddress: string,
+): Promise {
+ // First fetch the record for the price/currency values we need to pass
+ const { data: record, error: fetchErr } = await supabase
+ .from('fractionalization_records')
+ .select('*')
+ .eq('id', fractionalizationId)
+ .single();
+ if (fetchErr) throw fetchErr;
+
+ const rec = record as FractionalizationRecord;
+
+ // Client-side guard (the RPC enforces this too)
+ if (rec.status !== 'active') {
+ throw new Error(
+ rec.status === 'sold_out'
+ ? 'This fractionalization is sold out.'
+ : 'This fractionalization is no longer active.',
+ );
+ }
+ if (fractionCount > rec.available_fractions) {
+ throw new Error(
+ `Only ${rec.available_fractions} fraction${rec.available_fractions !== 1 ? 's' : ''} available.`,
+ );
+ }
+
+ // Atomic additive upsert + inventory decrement + price-history via RPC
+ const { data: posData, error: posErr } = await supabase.rpc('add_fractional_position', {
+ p_fractionalization_id: fractionalizationId,
+ p_lender_id: lenderId,
+ p_lender_address: lenderAddress,
+ p_fraction_count: fractionCount,
+ p_price_per_fraction: rec.price_per_fraction,
+ p_currency: rec.price_currency,
+ });
+ if (posErr) throw posErr;
+
+ return posData as FractionalPosition;
+}
+
+// ── Fractional position queries ───────────────────────────────────────────────
+
+/**
+ * Fetch all fractional positions for a lender, with the joined
+ * `fractionalization_records` row included.
+ */
+export async function fetchFractionalPositions(
+ lenderId: string,
+): Promise {
+ const { data, error } = await supabase
+ .from('fractional_positions')
+ .select('*, fractionalization:fractionalization_records(*)')
+ .eq('lender_id', lenderId)
+ .eq('status', 'held')
+ .order('purchased_at', { ascending: false });
+ if (error) throw error;
+ return (data as FractionalPosition[]) ?? [];
+}
+
+/**
+ * Build view models enriched with current valuation and dividend totals.
+ *
+ * All arithmetic is performed in `bigint` (stroops) to preserve the full
+ * i128 precision. Values are formatted to human-unit strings only at the
+ * very end via `formatAmount()`.
+ *
+ * Dividend totals are grouped per currency so mixed-currency positions are
+ * never incorrectly summed.
+ */
+export async function buildPositionViews(
+ positions: FractionalPosition[],
+): Promise {
+ if (positions.length === 0) return [];
+
+ const fracIds = positions.map(p => p.fractionalization_id);
+ const { data: divData } = await supabase
+ .from('dividend_distributions')
+ .select('fractionalization_id, per_fraction_amount, currency, status')
+ .in('fractionalization_id', fracIds)
+ .eq('status', 'distributed');
+
+ // Map fractionalization_id → total per_fraction_amount in stroops (bigint)
+ const dividendsByFrac = new Map();
+ for (const d of (divData ?? []) as {
+ fractionalization_id: string;
+ per_fraction_amount: string;
+ currency: string;
+ }[]) {
+ const prev = dividendsByFrac.get(d.fractionalization_id) ?? 0n;
+ dividendsByFrac.set(
+ d.fractionalization_id,
+ prev + toStroopsBigInt(d.per_fraction_amount),
+ );
+ }
+
+ return positions.map(p => {
+ const record = p.fractionalization as FractionalizationRecord;
+ const count = BigInt(p.fraction_count);
+
+ // Current value: keep in bigint until formatting
+ const currentUnitStroops = toStroopsBigInt(record?.price_per_fraction ?? '0');
+ const currentValueStroops = currentUnitStroops * count;
+ const currentValue = formatAmount(currentValueStroops);
+
+ // Dividends earned: bigint multiply to avoid precision loss
+ const totalDivPerFracStroops = dividendsByFrac.get(p.fractionalization_id) ?? 0n;
+ const totalDividendsStroops = totalDivPerFracStroops * count;
+ const totalDividendsEarned = formatAmount(totalDividendsStroops);
+
+ const ownershipPercent =
+ record?.total_fractions > 0
+ ? Math.round((p.fraction_count / record.total_fractions) * 10000) / 100
+ : 0;
+ return {
+ position: p,
+ record,
+ currentValue,
+ totalDividendsEarned,
+ ownershipPercent,
+ };
+ });
+}
+
+// ── Price history ─────────────────────────────────────────────────────────────
+
+/** Input type for `recordPricePoint`. */
+interface RecordPricePointInput {
+ fractionalizationId: string;
+ price: string;
+ currency: Currency;
+ volume: number;
+ source: 'primary' | 'secondary';
+}
+
+/**
+ * Append a price observation to `price_history`.
+ * Non-fatal: failures are swallowed because price history is supplementary.
+ */
+export async function recordPricePoint(input: RecordPricePointInput): Promise {
+ await supabase.from('price_history').insert({
+ fractionalization_id: input.fractionalizationId,
+ price: input.price,
+ currency: input.currency,
+ volume: input.volume,
+ source: input.source,
+ recorded_at: new Date().toISOString(),
+ });
+ // Non-fatal: price history is supplementary data
+}
+
+/**
+ * Fetch the most recent `limit` price-history points for a fractionalization,
+ * returned in chronological (ascending) order for chart rendering.
+ *
+ * We query descending-by-date to get the *newest* `limit` rows, then reverse
+ * so callers always receive chronological data.
+ */
+export async function fetchPriceHistory(
+ fractionalizationId: string,
+ limit = 60,
+): Promise {
+ const { data, error } = await supabase
+ .from('price_history')
+ .select('*')
+ .eq('fractionalization_id', fractionalizationId)
+ .order('recorded_at', { ascending: false })
+ .limit(limit);
+ if (error) throw error;
+ return ((data as PriceHistoryPoint[]) ?? []).reverse();
+}
+
+/**
+ * Batch-fetch price history for multiple fractionalization IDs in a single
+ * query and return them grouped by ID. Use this instead of calling
+ * `fetchPriceHistory` in a loop to avoid N+1 database round-trips.
+ *
+ * @param ids Array of fractionalization UUIDs to fetch history for.
+ * @param limitPerRecord Maximum points to retain per record (newest kept).
+ * @returns Map from fractionalization_id to chronologically-ordered points.
+ */
+export async function fetchPriceHistoryBatch(
+ ids: string[],
+ limitPerRecord = 20,
+): Promise