feat: add investment and redemption functionality for hedge funds
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s

- Implemented POST endpoint for investing in hedge funds, allowing users to invest a specified amount and receive shares based on the fund's NAV.
- Implemented POST endpoint for redeeming shares in hedge funds, enabling users to redeem shares for cash based on the current NAV.
- Created InvestPanel component for user interactions, including investing and redeeming shares, displaying current NAV, user balance, and holdings summary.
This commit is contained in:
2026-03-18 20:50:51 -04:00
parent 50b1b3472f
commit 8e808d5e7c
8 changed files with 436 additions and 10 deletions
+25 -7
View File
@@ -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
+89
View File
@@ -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,
})
}
+84
View File
@@ -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,
})
}
+194
View File
@@ -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 (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Tab bar */}
<div className="flex border-b border-surface-border">
{(['invest', 'redeem'] as const).map((t) => (
<button
key={t}
onClick={() => { setTab(t); setError(''); setSuccess('') }}
className={`flex-1 py-3 text-sm font-medium capitalize transition-colors ${
tab === t
? 'text-indigo-300 border-b-2 border-indigo-400 bg-indigo-500/5'
: 'text-slate-400 hover:text-slate-200'
}`}
>
{t}
</button>
))}
</div>
<div className="p-5 space-y-4">
{/* NAV info */}
<div className="flex items-center justify-between text-sm">
<span className="text-slate-400">Current NAV / share</span>
<span className="font-semibold">{formatCurrency(nav)}</span>
</div>
{/* Holdings summary */}
{userShares > 0 && (
<div className="bg-indigo-500/5 border border-indigo-500/20 rounded-lg px-4 py-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-slate-400">Your shares</span>
<span className="font-medium">{userShares.toFixed(4)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Current value</span>
<span className="font-medium">{formatCurrency(userShares * nav)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Avg buy NAV</span>
<span className="font-medium">{formatCurrency(userAvgNav)}</span>
</div>
</div>
)}
{tab === 'invest' ? (
<form onSubmit={handleInvest} className="space-y-3">
<div>
<label className="text-xs text-slate-400 mb-1 block">Amount to invest</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">$</span>
<input
type="number"
min="1"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
className="w-full bg-surface border border-surface-border rounded-lg pl-7 pr-3 py-2.5 text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<p className="text-xs text-slate-500 mt-1">
Balance: {formatCurrency(userBalance)}
</p>
</div>
{amountNum >= 1 && (
<p className="text-xs text-slate-400">
You&apos;ll receive <span className="text-white font-medium">{previewShares.toFixed(4)} shares</span>
</p>
)}
<button
type="submit"
disabled={loading || amountNum < 1}
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium transition-colors"
>
{loading ? 'Investing…' : 'Invest'}
</button>
</form>
) : (
<form onSubmit={handleRedeem} className="space-y-3">
<div>
<label className="text-xs text-slate-400 mb-1 block">Shares to redeem</label>
<input
type="number"
min="0"
step="0.0001"
max={userShares}
value={shares}
onChange={(e) => setShares(e.target.value)}
placeholder="0.0000"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-indigo-500"
/>
{userShares > 0 && (
<button
type="button"
onClick={() => setShares(String(userShares))}
className="text-xs text-indigo-400 hover:text-indigo-300 mt-1"
>
Max ({userShares.toFixed(4)})
</button>
)}
</div>
{sharesNum > 0 && (
<p className="text-xs text-slate-400">
You&apos;ll receive <span className="text-white font-medium">{formatCurrency(previewPayout)}</span>
</p>
)}
<button
type="submit"
disabled={loading || sharesNum <= 0 || sharesNum > userShares}
className="w-full py-2.5 rounded-lg bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium transition-colors"
>
{loading ? 'Redeeming…' : 'Redeem'}
</button>
</form>
)}
{error && <p className="text-red-400 text-sm">{error}</p>}
{success && <p className="text-emerald-400 text-sm">{success}</p>}
</div>
</div>
)
}
+36 -2
View File
@@ -6,6 +6,8 @@ import { formatCurrency, formatPnl, pnlColor } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import Link from 'next/link'
import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
import { calcFundNav } from '@/lib/pricing'
import InvestPanel from './InvestPanel'
export const dynamic = 'force-dynamic'
@@ -34,11 +36,23 @@ export default async function FundPage({ params }: { params: { slug: string } })
include: { user: { select: { id: true, username: true, displayUsername: true } } },
orderBy: { addedAt: 'asc' },
},
_count: { select: { investments: true } },
},
})
if (!fund) notFound()
// Fetch current user's balance and investment in this fund
const [currentUser, userInvestment] = session
? await Promise.all([
prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true, isFund: true } }),
prisma.fundInvestment.findUnique({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
select: { shares: true, avgNavAtBuy: true },
}),
])
: [null, null]
const isManager = session
? fund.managers.some((m) => m.userId === session.user.id)
: false
@@ -58,6 +72,8 @@ export default async function FundPage({ params }: { params: { slug: string } })
}, 0)
const totalValue = fund.user.balance + portfolioValue
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
const canInvest = !!session && !!currentUser && !currentUser.isFund
return (
<div className="max-w-4xl mx-auto space-y-8">
@@ -82,12 +98,14 @@ export default async function FundPage({ params }: { params: { slug: string } })
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{[
{ label: 'Cash', value: formatCurrency(fund.user.balance) },
{ label: 'Invested', value: formatCurrency(portfolioValue) },
{ label: 'Unrealized P&L', value: formatPnl(unrealizedPnl), colorClass: pnlColor(unrealizedPnl) },
{ label: 'Managers', value: String(fund.managers.length) },
{ label: 'NAV / Share', value: formatCurrency(nav) },
{ label: 'Shares Out.', value: fund.sharesOutstanding.toFixed(2) },
{ label: 'Investors', value: String(fund._count.investments) },
].map(({ label, value, colorClass }) => (
<div key={label} className="bg-surface-card border border-surface-border rounded-xl p-4">
<p className="text-xs text-slate-500 mb-1">{label}</p>
@@ -127,6 +145,22 @@ export default async function FundPage({ params }: { params: { slug: string } })
</div>
)}
{/* Invest / Redeem panel */}
{canInvest && (
<div>
<h2 className="font-semibold mb-3 flex items-center gap-2 text-sm text-slate-400 uppercase tracking-wider">
Invest in this Fund
</h2>
<InvestPanel
fundSlug={fund.slug}
nav={nav}
userBalance={currentUser!.balance}
userShares={userInvestment?.shares ?? 0}
userAvgNav={userInvestment?.avgNavAtBuy ?? 0}
/>
</div>
)}
{/* Open positions */}
{positions.length > 0 && (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
+6
View File
@@ -39,6 +39,12 @@ export function getBalanceTier(balance: number): BalanceTier {
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
}
/** Calculate NAV (net asset value) per fund share. Returns 1.00 if no shares outstanding. */
export function calcFundNav(totalValue: number, sharesOutstanding: number): number {
if (sharesOutstanding <= 0) return 1.00
return totalValue / sharesOutstanding
}
/**
* Calculate the cost/proceeds and realized P&L for a trade.
*
+1
View File
@@ -11,5 +11,6 @@ export const config = {
'/api/user/:path*',
'/api/admin/:path*',
'/api/lottery/:path*',
'/api/funds/:path*',
],
}
+1 -1
View File
File diff suppressed because one or more lines are too long