diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fe8902f..692977b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,21 +26,39 @@ model User { passwordResets PasswordReset[] managedFunds FundManager[] fund HedgeFund? + fundInvestments FundInvestment[] } model HedgeFund { - id String @id @default(cuid()) - name String @unique - slug String @unique // lowercase, URL-safe - userId String @unique // shadow User account that holds positions/trades/balance - user User @relation(fields: [userId], references: [id]) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + name String @unique + slug String @unique // lowercase, URL-safe + userId String @unique // shadow User account that holds positions/trades/balance + user User @relation(fields: [userId], references: [id]) + sharesOutstanding Float @default(0) // total fund shares currently in circulation + createdAt DateTime @default(now()) - managers FundManager[] + managers FundManager[] + investments FundInvestment[] @@index([slug]) } +model FundInvestment { + id String @id @default(cuid()) + fundId String + fund HedgeFund @relation(fields: [fundId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + shares Float // fund shares held by this investor + avgNavAtBuy Float // NAV per share at time of purchase (for display only) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([fundId, userId]) + @@index([userId]) +} + model FundManager { id String @id @default(cuid()) fundId String diff --git a/src/app/api/funds/[slug]/invest/route.ts b/src/app/api/funds/[slug]/invest/route.ts new file mode 100644 index 0000000..ef0bfb3 --- /dev/null +++ b/src/app/api/funds/[slug]/invest/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { calcFundNav } from '@/lib/pricing' + +export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const slug = decodeURIComponent(params.slug).toLowerCase() + const body = await req.json() + const amount = Number(body.amount) + + if (!amount || amount < 1) { + return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 }) + } + + const fund = await prisma.hedgeFund.findUnique({ + where: { slug }, + include: { + user: { + select: { + balance: true, + positions: { + where: { shares: { gt: 0 } }, + select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } }, + }, + }, + }, + }, + }) + + if (!fund) return NextResponse.json({ error: 'Fund not found' }, { status: 404 }) + + // Verify investor is not the fund's own shadow user + const investor = await prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true, isFund: true } }) + if (!investor || investor.isFund) { + return NextResponse.json({ error: 'Invalid account' }, { status: 403 }) + } + if (investor.balance < amount) { + return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 }) + } + + const portfolioValue = fund.user.positions.reduce((sum, p) => { + const val = p.positionType === 'LONG' + ? p.shares * p.hashtag.currentPrice + : p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares + return sum + val + }, 0) + const totalValue = fund.user.balance + portfolioValue + const nav = calcFundNav(totalValue, fund.sharesOutstanding) + const sharesToMint = amount / nav + + // Weighted average NAV at buy for display + const existingInvestment = await prisma.fundInvestment.findUnique({ + where: { fundId_userId: { fundId: fund.id, userId: session.user.id } }, + select: { shares: true, avgNavAtBuy: true }, + }) + + let newAvgNav: number + if (existingInvestment) { + const totalShares = existingInvestment.shares + sharesToMint + newAvgNav = (existingInvestment.avgNavAtBuy * existingInvestment.shares + nav * sharesToMint) / totalShares + } else { + newAvgNav = nav + } + + const [updatedInvestor] = await prisma.$transaction([ + // Deduct from investor (returns updated user with new balance) + prisma.user.update({ where: { id: session.user.id }, data: { balance: { decrement: amount } } }), + // Add to fund's cash + prisma.user.update({ where: { id: fund.userId }, data: { balance: { increment: amount } } }), + // Upsert FundInvestment record + prisma.fundInvestment.upsert({ + where: { fundId_userId: { fundId: fund.id, userId: session.user.id } }, + create: { fundId: fund.id, userId: session.user.id, shares: sharesToMint, avgNavAtBuy: newAvgNav }, + update: { shares: { increment: sharesToMint }, avgNavAtBuy: newAvgNav }, + }), + // Increment fund shares outstanding + prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { increment: sharesToMint } } }), + ]) + + return NextResponse.json({ + shares: sharesToMint, + nav, + newBalance: updatedInvestor.balance, + }) +} diff --git a/src/app/api/funds/[slug]/redeem/route.ts b/src/app/api/funds/[slug]/redeem/route.ts new file mode 100644 index 0000000..db39638 --- /dev/null +++ b/src/app/api/funds/[slug]/redeem/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { calcFundNav } from '@/lib/pricing' + +export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const slug = decodeURIComponent(params.slug).toLowerCase() + const body = await req.json() + const sharesToRedeem = Number(body.shares) + + if (!sharesToRedeem || sharesToRedeem <= 0) { + return NextResponse.json({ error: 'Invalid share amount' }, { status: 400 }) + } + + const fund = await prisma.hedgeFund.findUnique({ + where: { slug }, + include: { + user: { + select: { + balance: true, + positions: { + where: { shares: { gt: 0 } }, + select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } }, + }, + }, + }, + }, + }) + + if (!fund) return NextResponse.json({ error: 'Fund not found' }, { status: 404 }) + + const investment = await prisma.fundInvestment.findUnique({ + where: { fundId_userId: { fundId: fund.id, userId: session.user.id } }, + select: { shares: true }, + }) + + if (!investment || investment.shares < sharesToRedeem) { + return NextResponse.json({ error: 'Insufficient fund shares' }, { status: 400 }) + } + + const portfolioValue = fund.user.positions.reduce((sum, p) => { + const val = p.positionType === 'LONG' + ? p.shares * p.hashtag.currentPrice + : p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares + return sum + val + }, 0) + const totalValue = fund.user.balance + portfolioValue + const nav = calcFundNav(totalValue, fund.sharesOutstanding) + const payout = sharesToRedeem * nav + + if (fund.user.balance < payout) { + return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 }) + } + + const remainingShares = investment.shares - sharesToRedeem + + const [updatedInvestor] = await prisma.$transaction([ + // Return cash to investor + prisma.user.update({ where: { id: session.user.id }, data: { balance: { increment: payout } } }), + // Deduct from fund's cash + prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }), + // Update or delete FundInvestment + ...(remainingShares > 0 + ? [prisma.fundInvestment.update({ + where: { fundId_userId: { fundId: fund.id, userId: session.user.id } }, + data: { shares: remainingShares }, + })] + : [prisma.fundInvestment.delete({ + where: { fundId_userId: { fundId: fund.id, userId: session.user.id } }, + })]), + // Decrement fund shares outstanding + prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: sharesToRedeem } } }), + ]) + + return NextResponse.json({ + payout, + nav, + newBalance: updatedInvestor.balance, + }) +} diff --git a/src/app/fund/[slug]/InvestPanel.tsx b/src/app/fund/[slug]/InvestPanel.tsx new file mode 100644 index 0000000..e3074e8 --- /dev/null +++ b/src/app/fund/[slug]/InvestPanel.tsx @@ -0,0 +1,194 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { formatCurrency } from '@/lib/utils' + +interface Props { + fundSlug: string + nav: number + userBalance: number + userShares: number + userAvgNav: number +} + +export default function InvestPanel({ fundSlug, nav, userBalance, userShares, userAvgNav }: Props) { + const router = useRouter() + const [tab, setTab] = useState<'invest' | 'redeem'>('invest') + const [amount, setAmount] = useState('') + const [shares, setShares] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + + const amountNum = parseFloat(amount) || 0 + const sharesNum = parseFloat(shares) || 0 + const previewShares = nav > 0 ? amountNum / nav : 0 + const previewPayout = sharesNum * nav + + async function handleInvest(e: React.FormEvent) { + e.preventDefault() + setError('') + setSuccess('') + if (amountNum < 1) { setError('Minimum $1'); return } + setLoading(true) + try { + const res = await fetch(`/api/funds/${fundSlug}/invest`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount: amountNum }), + }) + const data = await res.json() + if (!res.ok) { setError(data.error ?? 'Failed'); return } + setSuccess(`Invested! You received ${data.shares.toFixed(4)} shares at NAV ${formatCurrency(data.nav)}.`) + setAmount('') + router.refresh() + } finally { + setLoading(false) + } + } + + async function handleRedeem(e: React.FormEvent) { + e.preventDefault() + setError('') + setSuccess('') + if (sharesNum <= 0) { setError('Enter shares to redeem'); return } + setLoading(true) + try { + const res = await fetch(`/api/funds/${fundSlug}/redeem`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shares: sharesNum }), + }) + const data = await res.json() + if (!res.ok) { setError(data.error ?? 'Failed'); return } + setSuccess(`Redeemed ${sharesNum.toFixed(4)} shares for ${formatCurrency(data.payout)}.`) + setShares('') + router.refresh() + } finally { + setLoading(false) + } + } + + return ( +
{error}
} + {success &&{success}
} +{label}
@@ -127,6 +145,22 @@ export default async function FundPage({ params }: { params: { slug: string } })