feat: enhance fund investment and redemption logging, and improve positions display
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
This commit is contained in:
@@ -43,6 +43,7 @@ model HedgeFund {
|
|||||||
managers FundManager[]
|
managers FundManager[]
|
||||||
investments FundInvestment[]
|
investments FundInvestment[]
|
||||||
navHistory FundNavHistory[]
|
navHistory FundNavHistory[]
|
||||||
|
trades Trade[]
|
||||||
|
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
}
|
}
|
||||||
@@ -178,6 +179,8 @@ model Trade {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
hashtagId String?
|
hashtagId String?
|
||||||
hashtag Hashtag? @relation(fields: [hashtagId], references: [id])
|
hashtag Hashtag? @relation(fields: [hashtagId], references: [id])
|
||||||
|
fundId String?
|
||||||
|
fund HedgeFund? @relation(fields: [fundId], references: [id], onDelete: SetNull)
|
||||||
type TradeType
|
type TradeType
|
||||||
shares Float
|
shares Float
|
||||||
price Float // price per share at time of trade (or win amount for LOTTERY_WIN)
|
price Float // price per share at time of trade (or win amount for LOTTERY_WIN)
|
||||||
@@ -187,6 +190,7 @@ model Trade {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([hashtagId])
|
@@index([hashtagId])
|
||||||
|
@@index([fundId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,4 +210,6 @@ enum TradeType {
|
|||||||
DONATION // keepHistory reset: user was in the green — donated their portfolio
|
DONATION // keepHistory reset: user was in the green — donated their portfolio
|
||||||
BANKRUPTCY // keepHistory reset: user was in the red — debts cleared
|
BANKRUPTCY // keepHistory reset: user was in the red — debts cleared
|
||||||
ACCOUNT_OPEN // keepHistory reset: new $2000 account opening entry
|
ACCOUNT_OPEN // keepHistory reset: new $2000 account opening entry
|
||||||
|
FUND_INVEST // invested cash into a hedge fund
|
||||||
|
FUND_REDEEM // redeemed shares from a hedge fund
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
}),
|
}),
|
||||||
// Increment fund shares outstanding
|
// Increment fund shares outstanding
|
||||||
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { increment: sharesToMint } } }),
|
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { increment: sharesToMint } } }),
|
||||||
|
// Log trade in activity history
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId: session.user.id, fundId: fund.id, type: 'FUND_INVEST', shares: sharesToMint, price: nav, total: amount, profit: 0 },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
|
|
||||||
const investment = await prisma.fundInvestment.findUnique({
|
const investment = await prisma.fundInvestment.findUnique({
|
||||||
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
||||||
select: { shares: true },
|
select: { shares: true, avgNavAtBuy: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!investment || investment.shares < sharesToRedeem) {
|
if (!investment || investment.shares < sharesToRedeem) {
|
||||||
@@ -57,6 +57,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remainingShares = Math.round((investment.shares - sharesToRedeem) * 1e6) / 1e6
|
const remainingShares = Math.round((investment.shares - sharesToRedeem) * 1e6) / 1e6
|
||||||
|
const profit = round2(payout - sharesToRedeem * investment.avgNavAtBuy)
|
||||||
|
|
||||||
const [updatedInvestor] = await prisma.$transaction([
|
const [updatedInvestor] = await prisma.$transaction([
|
||||||
// Return cash to investor
|
// Return cash to investor
|
||||||
@@ -74,6 +75,10 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
|
|||||||
})]),
|
})]),
|
||||||
// Decrement fund shares outstanding
|
// Decrement fund shares outstanding
|
||||||
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: sharesToRedeem } } }),
|
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: sharesToRedeem } } }),
|
||||||
|
// Log trade in activity history
|
||||||
|
prisma.trade.create({
|
||||||
|
data: { userId: session.user.id, fundId: fund.id, type: 'FUND_REDEEM', shares: sharesToRedeem, price: nav, total: payout, profit },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
skip: (page - 1) * PAGE_SIZE,
|
skip: (page - 1) * PAGE_SIZE,
|
||||||
include: {
|
include: {
|
||||||
hashtag: { select: { tag: true, displayTag: true } },
|
hashtag: { select: { tag: true, displayTag: true } },
|
||||||
|
fund: { select: { name: true, slug: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -61,6 +62,7 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
const isLottery = t.type === 'LOTTERY_WIN'
|
const isLottery = t.type === 'LOTTERY_WIN'
|
||||||
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
|
||||||
const isSystemReset = t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'ACCOUNT_OPEN'
|
const isSystemReset = t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'ACCOUNT_OPEN'
|
||||||
|
const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM'
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -74,6 +76,8 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
? 'bg-purple-500/15 text-purple-400'
|
? 'bg-purple-500/15 text-purple-400'
|
||||||
: t.type === 'ACCOUNT_OPEN'
|
: t.type === 'ACCOUNT_OPEN'
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
|
: isFundTrade
|
||||||
|
? 'bg-indigo-500/15 text-indigo-400'
|
||||||
: t.type.startsWith('BUY')
|
: t.type.startsWith('BUY')
|
||||||
? 'bg-emerald-500/15 text-emerald-400'
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
: 'bg-red-500/15 text-red-400'
|
: 'bg-red-500/15 text-red-400'
|
||||||
@@ -92,6 +96,10 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
? 'Bankruptcy declared'
|
? 'Bankruptcy declared'
|
||||||
: 'Account opened'}
|
: 'Account opened'}
|
||||||
</span>
|
</span>
|
||||||
|
) : isFundTrade ? (
|
||||||
|
<Link href={`/fund/${t.fund!.slug}`} className="hover:text-indigo-300">
|
||||||
|
{t.fund!.name}
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`/hashtag/${t.hashtag!.tag}`}
|
href={`/hashtag/${t.hashtag!.tag}`}
|
||||||
@@ -113,6 +121,14 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
|
|||||||
<p className="text-slate-500">{formatCurrency(t.total)}</p>
|
<p className="text-slate-500">{formatCurrency(t.total)}</p>
|
||||||
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
|
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
|
||||||
</>
|
</>
|
||||||
|
) : isFundTrade ? (
|
||||||
|
<>
|
||||||
|
<p>{formatNumber(t.shares, 6)} sh @ {formatCurrency(t.price)}</p>
|
||||||
|
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
|
||||||
|
{t.type === 'FUND_REDEEM' && (
|
||||||
|
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
|
|||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
|
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
|
||||||
|
import { calcFundNav } from '@/lib/pricing'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Coins, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
|
import { Coins, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
|
||||||
import { AutoRefresh } from '@/components/AutoRefresh'
|
import { AutoRefresh } from '@/components/AutoRefresh'
|
||||||
@@ -96,6 +97,49 @@ export default async function PositionsPage({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const rawFundInvestments = await prisma.fundInvestment.findMany({
|
||||||
|
where: { userId: session.user.id, shares: { gt: 0 } },
|
||||||
|
include: {
|
||||||
|
fund: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
sharesOutstanding: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
balance: true,
|
||||||
|
positions: {
|
||||||
|
where: { shares: { gt: 0 } },
|
||||||
|
select: {
|
||||||
|
shares: true,
|
||||||
|
avgBuyPrice: true,
|
||||||
|
positionType: true,
|
||||||
|
hashtag: { select: { currentPrice: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fundHoldings = rawFundInvestments.map((inv) => {
|
||||||
|
const fundPortfolioValue = inv.fund.user.positions.reduce((sum, p) => {
|
||||||
|
const val = p.positionType === 'LONG'
|
||||||
|
? p.shares * p.hashtag.currentPrice
|
||||||
|
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||||
|
return sum + val
|
||||||
|
}, 0)
|
||||||
|
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
|
||||||
|
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
|
||||||
|
const currentValue = inv.shares * nav
|
||||||
|
const costBasis = inv.shares * inv.avgNavAtBuy
|
||||||
|
const pnl = currentValue - costBasis
|
||||||
|
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
|
||||||
|
return { ...inv, nav, currentValue, costBasis, pnl, pnlPct }
|
||||||
|
})
|
||||||
|
|
||||||
const positions = rawPositions.map((pos) => {
|
const positions = rawPositions.map((pos) => {
|
||||||
const pnl =
|
const pnl =
|
||||||
pos.positionType === 'LONG'
|
pos.positionType === 'LONG'
|
||||||
@@ -137,7 +181,7 @@ export default async function PositionsPage({
|
|||||||
<h1 className="text-2xl font-bold">Open Positions</h1>
|
<h1 className="text-2xl font-bold">Open Positions</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{positions.length === 0 ? (
|
{positions.length === 0 && fundHoldings.length === 0 ? (
|
||||||
<div className="text-center py-16 text-slate-500">
|
<div className="text-center py-16 text-slate-500">
|
||||||
<Coins className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
<Coins className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||||
<p>You have no open positions.</p>
|
<p>You have no open positions.</p>
|
||||||
@@ -206,6 +250,41 @@ export default async function PositionsPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{fundHoldings.length > 0 && (
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_5rem_5rem_6rem_5rem_6rem] gap-4 px-4 py-2 border-b border-surface-border text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
<span>Fund</span>
|
||||||
|
<span className="text-right">Shares</span>
|
||||||
|
<span className="hidden sm:block text-right">Avg NAV</span>
|
||||||
|
<span className="hidden sm:block text-right">Cur NAV</span>
|
||||||
|
<span className="hidden sm:block text-right">Cost basis</span>
|
||||||
|
<span className="hidden sm:block text-right">Value</span>
|
||||||
|
<span className="text-right">P&L</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-surface-border">
|
||||||
|
{fundHoldings.map((inv) => (
|
||||||
|
<div key={inv.id} className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_5rem_5rem_6rem_5rem_6rem] gap-4 items-center px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link href={`/fund/${inv.fund.slug}`} className="font-medium hover:text-indigo-300 truncate block">
|
||||||
|
{inv.fund.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs font-medium px-1.5 py-0.5 rounded bg-indigo-500/15 text-indigo-400">FUND</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-right text-sm">{formatNumber(inv.shares, 6)}</span>
|
||||||
|
<span className="hidden sm:block text-right text-sm">{formatCurrency(inv.avgNavAtBuy)}</span>
|
||||||
|
<span className="hidden sm:block text-right text-sm">{formatCurrency(inv.nav)}</span>
|
||||||
|
<span className="hidden sm:block text-right text-sm text-slate-400">{formatCurrency(inv.costBasis)}</span>
|
||||||
|
<span className="hidden sm:block text-right text-sm">{formatCurrency(inv.currentValue)}</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-sm font-medium ${pnlColor(inv.pnl)}`}>{formatPnl(inv.pnl)}</p>
|
||||||
|
<p className={`text-xs ${pnlColor(inv.pnlPct)}`}>{inv.pnlPct >= 0 ? '+' : ''}{inv.pnlPct.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user