From 840345d093dd4d04545726601100e547e27cd59e Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Fri, 20 Mar 2026 19:47:59 -0400 Subject: [PATCH] feat: enhance fund investment and redemption logging, and improve positions display --- prisma/schema.prisma | 6 ++ src/app/api/funds/[slug]/invest/route.ts | 4 ++ src/app/api/funds/[slug]/redeem/route.ts | 7 +- src/app/history/page.tsx | 22 ++++++- src/app/positions/page.tsx | 81 +++++++++++++++++++++++- 5 files changed, 115 insertions(+), 5 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d47643a..6ca2939 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,7 @@ model HedgeFund { managers FundManager[] investments FundInvestment[] navHistory FundNavHistory[] + trades Trade[] @@index([slug]) } @@ -178,6 +179,8 @@ model Trade { user User @relation(fields: [userId], references: [id], onDelete: Cascade) hashtagId String? hashtag Hashtag? @relation(fields: [hashtagId], references: [id]) + fundId String? + fund HedgeFund? @relation(fields: [fundId], references: [id], onDelete: SetNull) type TradeType shares Float price Float // price per share at time of trade (or win amount for LOTTERY_WIN) @@ -187,6 +190,7 @@ model Trade { @@index([userId]) @@index([hashtagId]) + @@index([fundId]) @@index([createdAt]) } @@ -206,4 +210,6 @@ enum TradeType { DONATION // keepHistory reset: user was in the green — donated their portfolio BANKRUPTCY // keepHistory reset: user was in the red — debts cleared 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 } diff --git a/src/app/api/funds/[slug]/invest/route.ts b/src/app/api/funds/[slug]/invest/route.ts index 43ae891..2d63b53 100644 --- a/src/app/api/funds/[slug]/invest/route.ts +++ b/src/app/api/funds/[slug]/invest/route.ts @@ -79,6 +79,10 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin }), // Increment fund shares outstanding 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({ diff --git a/src/app/api/funds/[slug]/redeem/route.ts b/src/app/api/funds/[slug]/redeem/route.ts index cc9955e..4f11d05 100644 --- a/src/app/api/funds/[slug]/redeem/route.ts +++ b/src/app/api/funds/[slug]/redeem/route.ts @@ -35,7 +35,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin const investment = await prisma.fundInvestment.findUnique({ where: { fundId_userId: { fundId: fund.id, userId: session.user.id } }, - select: { shares: true }, + select: { shares: true, avgNavAtBuy: true }, }) 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 profit = round2(payout - sharesToRedeem * investment.avgNavAtBuy) const [updatedInvestor] = await prisma.$transaction([ // Return cash to investor @@ -74,6 +75,10 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin })]), // Decrement fund shares outstanding 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({ diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index 66e5c94..d564fad 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -30,6 +30,7 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) { skip: (page - 1) * PAGE_SIZE, include: { 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 isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT' const isSystemReset = t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'ACCOUNT_OPEN' + const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM' return (
@@ -74,9 +76,11 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) { ? 'bg-purple-500/15 text-purple-400' : t.type === 'ACCOUNT_OPEN' ? 'bg-emerald-500/15 text-emerald-400' - : t.type.startsWith('BUY') - ? 'bg-emerald-500/15 text-emerald-400' - : 'bg-red-500/15 text-red-400' + : isFundTrade + ? 'bg-indigo-500/15 text-indigo-400' + : t.type.startsWith('BUY') + ? 'bg-emerald-500/15 text-emerald-400' + : 'bg-red-500/15 text-red-400' }`} > {isLiquidation ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')} @@ -92,6 +96,10 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) { ? 'Bankruptcy declared' : 'Account opened'} + ) : isFundTrade ? ( + + {t.fund!.name} + ) : ( {formatCurrency(t.total)}

{formatPnl(t.profit)}

+ ) : isFundTrade ? ( + <> +

{formatNumber(t.shares, 6)} sh @ {formatCurrency(t.price)}

+

{formatCurrency(t.total)}

+ {t.type === 'FUND_REDEEM' && ( +

{formatPnl(t.profit)}

+ )} + ) : ( <>

{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}

diff --git a/src/app/positions/page.tsx b/src/app/positions/page.tsx index f2e2cd5..5b5a8cf 100644 --- a/src/app/positions/page.tsx +++ b/src/app/positions/page.tsx @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { redirect } from 'next/navigation' import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils' +import { calcFundNav } from '@/lib/pricing' import Link from 'next/link' import { Coins, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react' 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 pnl = pos.positionType === 'LONG' @@ -137,7 +181,7 @@ export default async function PositionsPage({

Open Positions

- {positions.length === 0 ? ( + {positions.length === 0 && fundHoldings.length === 0 ? (

You have no open positions.

@@ -206,6 +250,41 @@ export default async function PositionsPage({
)} + + {fundHoldings.length > 0 && ( +
+
+ Fund + Shares + Avg NAV + Cur NAV + Cost basis + Value + P&L +
+
+ {fundHoldings.map((inv) => ( +
+
+ + {inv.fund.name} + + FUND +
+ {formatNumber(inv.shares, 6)} + {formatCurrency(inv.avgNavAtBuy)} + {formatCurrency(inv.nav)} + {formatCurrency(inv.costBasis)} + {formatCurrency(inv.currentValue)} +
+

{formatPnl(inv.pnl)}

+

{inv.pnlPct >= 0 ? '+' : ''}{inv.pnlPct.toFixed(1)}%

+
+
+ ))} +
+
+ )} ) }