feat: add investment and redemption functionality for hedge funds
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
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:
@@ -26,6 +26,7 @@ model User {
|
|||||||
passwordResets PasswordReset[]
|
passwordResets PasswordReset[]
|
||||||
managedFunds FundManager[]
|
managedFunds FundManager[]
|
||||||
fund HedgeFund?
|
fund HedgeFund?
|
||||||
|
fundInvestments FundInvestment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model HedgeFund {
|
model HedgeFund {
|
||||||
@@ -34,13 +35,30 @@ model HedgeFund {
|
|||||||
slug String @unique // lowercase, URL-safe
|
slug String @unique // lowercase, URL-safe
|
||||||
userId String @unique // shadow User account that holds positions/trades/balance
|
userId String @unique // shadow User account that holds positions/trades/balance
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
sharesOutstanding Float @default(0) // total fund shares currently in circulation
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
managers FundManager[]
|
managers FundManager[]
|
||||||
|
investments FundInvestment[]
|
||||||
|
|
||||||
@@index([slug])
|
@@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 {
|
model FundManager {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
fundId String
|
fundId String
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { formatCurrency, formatPnl, pnlColor } from '@/lib/utils'
|
|||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
|
import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
|
||||||
|
import { calcFundNav } from '@/lib/pricing'
|
||||||
|
import InvestPanel from './InvestPanel'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
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 } } },
|
include: { user: { select: { id: true, username: true, displayUsername: true } } },
|
||||||
orderBy: { addedAt: 'asc' },
|
orderBy: { addedAt: 'asc' },
|
||||||
},
|
},
|
||||||
|
_count: { select: { investments: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!fund) notFound()
|
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
|
const isManager = session
|
||||||
? fund.managers.some((m) => m.userId === session.user.id)
|
? fund.managers.some((m) => m.userId === session.user.id)
|
||||||
: false
|
: false
|
||||||
@@ -58,6 +72,8 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
|||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
const totalValue = fund.user.balance + portfolioValue
|
const totalValue = fund.user.balance + portfolioValue
|
||||||
|
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
||||||
|
const canInvest = !!session && !!currentUser && !currentUser.isFund
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
@@ -82,12 +98,14 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* 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: 'Cash', value: formatCurrency(fund.user.balance) },
|
||||||
{ label: 'Invested', value: formatCurrency(portfolioValue) },
|
{ label: 'Invested', value: formatCurrency(portfolioValue) },
|
||||||
{ label: 'Unrealized P&L', value: formatPnl(unrealizedPnl), colorClass: pnlColor(unrealizedPnl) },
|
{ 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 }) => (
|
].map(({ label, value, colorClass }) => (
|
||||||
<div key={label} className="bg-surface-card border border-surface-border rounded-xl p-4">
|
<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>
|
<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>
|
</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 */}
|
{/* Open positions */}
|
||||||
{positions.length > 0 && (
|
{positions.length > 0 && (
|
||||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export function getBalanceTier(balance: number): BalanceTier {
|
|||||||
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
|
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.
|
* Calculate the cost/proceeds and realized P&L for a trade.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ export const config = {
|
|||||||
'/api/user/:path*',
|
'/api/user/:path*',
|
||||||
'/api/admin/:path*',
|
'/api/admin/:path*',
|
||||||
'/api/lottery/:path*',
|
'/api/lottery/:path*',
|
||||||
|
'/api/funds/:path*',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user