From 54ecf35cf32244ad2894f922b8d20e828cb4ba1a Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Thu, 19 Mar 2026 18:11:12 -0400 Subject: [PATCH] feat: implement NAV calculation and payout for fund investments on user deletion --- src/app/api/admin/funds/[fundId]/route.ts | 40 +++++++++++++++++++++-- src/app/api/admin/users/[userId]/route.ts | 33 +++++++++++++++++-- src/app/api/user/me/route.ts | 33 +++++++++++++++++-- src/components/Navbar.tsx | 37 ++++++++++++++++----- 4 files changed, 125 insertions(+), 18 deletions(-) diff --git a/src/app/api/admin/funds/[fundId]/route.ts b/src/app/api/admin/funds/[fundId]/route.ts index a390e37..a5b6221 100644 --- a/src/app/api/admin/funds/[fundId]/route.ts +++ b/src/app/api/admin/funds/[fundId]/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { z } from 'zod' +import { calcFundNav } from '@/lib/pricing' const patchSchema = z.object({ addManagerUsername: z.string().optional(), @@ -87,11 +88,44 @@ export async function DELETE( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const fund = await prisma.hedgeFund.findUnique({ where: { id: params.fundId } }) + const fund = await prisma.hedgeFund.findUnique({ + where: { id: params.fundId }, + select: { + id: true, + userId: true, + sharesOutstanding: true, + investments: { select: { userId: true, shares: true } }, + 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 }) - // Must delete fund first (it holds the FK to User), then delete the shadow user - // (which cascades positions and trades). + // Compute mark-to-market NAV so investors are paid their fair share + 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 nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding) + + // Pay out each investor at current NAV before wiping records + for (const inv of fund.investments) { + const payout = Math.max(0, inv.shares * nav) + if (payout > 0) { + await prisma.user.update({ where: { id: inv.userId }, data: { balance: { increment: payout } } }) + } + } + + // Delete fund first (FK constraint), then shadow user (cascades positions/trades) await prisma.hedgeFund.delete({ where: { id: fund.id } }) await prisma.user.delete({ where: { id: fund.userId } }) diff --git a/src/app/api/admin/users/[userId]/route.ts b/src/app/api/admin/users/[userId]/route.ts index f483d08..4b1db88 100644 --- a/src/app/api/admin/users/[userId]/route.ts +++ b/src/app/api/admin/users/[userId]/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { z } from 'zod' +import { calcFundNav } from '@/lib/pricing' const schema = z.object({ balance: z.number().min(0).optional(), @@ -68,12 +69,38 @@ export async function DELETE( ) } - // Reconcile fund sharesOutstanding before cascade delete + // Redeem each fund investment at current NAV — deduct payout from fund cash for (const inv of user.fundInvestments) { - await prisma.hedgeFund.update({ + const fund = await prisma.hedgeFund.findUnique({ where: { id: inv.fundId }, - data: { sharesOutstanding: { decrement: inv.shares } }, + select: { + id: true, + userId: true, + sharesOutstanding: true, + user: { + select: { + balance: true, + positions: { + where: { shares: { gt: 0 } }, + select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } }, + }, + }, + }, + }, }) + if (!fund) continue + 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 nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding) + const payout = inv.shares * nav + await prisma.$transaction([ + prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }), + prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }), + ]) } await prisma.user.delete({ where: { id: params.userId } }) diff --git a/src/app/api/user/me/route.ts b/src/app/api/user/me/route.ts index 012ec8b..f4ef773 100644 --- a/src/app/api/user/me/route.ts +++ b/src/app/api/user/me/route.ts @@ -2,6 +2,7 @@ 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' const USERNAME_RE = /^[a-z0-9_]{3,20}$/ // validated after toLowerCase @@ -111,12 +112,38 @@ export async function DELETE() { if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 }) if (user.isFund) return NextResponse.json({ error: 'Fund accounts cannot be self-deleted.' }, { status: 400 }) - // Reconcile fund sharesOutstanding before user is deleted + // Redeem each fund investment at current NAV — deduct payout from fund cash for (const inv of user.fundInvestments) { - await prisma.hedgeFund.update({ + const fund = await prisma.hedgeFund.findUnique({ where: { id: inv.fundId }, - data: { sharesOutstanding: { decrement: inv.shares } }, + select: { + id: true, + userId: true, + sharesOutstanding: true, + user: { + select: { + balance: true, + positions: { + where: { shares: { gt: 0 } }, + select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } }, + }, + }, + }, + }, }) + if (!fund) continue + 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 nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding) + const payout = inv.shares * nav + await prisma.$transaction([ + prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }), + prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }), + ]) } await prisma.user.delete({ where: { id: session.user.id } }) diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 9e86a51..ffad177 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { useSession, signOut } from 'next-auth/react' import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react' -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { useRouter } from 'next/navigation' import { formatCurrency } from '@/lib/utils' import { normalizeTag } from '@/lib/utils' @@ -155,16 +155,35 @@ export function Navbar() { // Lazy balance fetcher so the navbar always shows current value function BalanceBadge({ userId }: { userId: string }) { - // We read balance from the API to stay fresh; use SWR-style approach const [balance, setBalance] = useState(null) - // One-shot fetch on mount - if (typeof window !== 'undefined' && balance === null) { - fetch('/api/user/me') - .then((r) => r.json()) - .then((d) => setBalance(d.balance ?? null)) - .catch(() => {}) - } + useEffect(() => { + let cancelled = false + + function fetchBalance() { + fetch('/api/user/me') + .then((r) => r.json()) + .then((d) => { if (!cancelled) setBalance(d.balance ?? null) }) + .catch(() => {}) + } + + fetchBalance() + + // Re-fetch every 30 seconds + const interval = setInterval(fetchBalance, 30_000) + + // Re-fetch when the tab regains focus + function onVisible() { + if (document.visibilityState === 'visible') fetchBalance() + } + document.addEventListener('visibilitychange', onVisible) + + return () => { + cancelled = true + clearInterval(interval) + document.removeEventListener('visibilitychange', onVisible) + } + }, [userId]) if (balance === null) return null