feat: implement NAV calculation and payout for fund investments on user deletion
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s

This commit is contained in:
2026-03-19 18:11:12 -04:00
parent f3f3591e34
commit 54ecf35cf3
4 changed files with 125 additions and 18 deletions
+37 -3
View File
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { z } from 'zod' import { z } from 'zod'
import { calcFundNav } from '@/lib/pricing'
const patchSchema = z.object({ const patchSchema = z.object({
addManagerUsername: z.string().optional(), addManagerUsername: z.string().optional(),
@@ -87,11 +88,44 @@ export async function DELETE(
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) 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 }) 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 // Compute mark-to-market NAV so investors are paid their fair share
// (which cascades positions and trades). 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.hedgeFund.delete({ where: { id: fund.id } })
await prisma.user.delete({ where: { id: fund.userId } }) await prisma.user.delete({ where: { id: fund.userId } })
+30 -3
View File
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { z } from 'zod' import { z } from 'zod'
import { calcFundNav } from '@/lib/pricing'
const schema = z.object({ const schema = z.object({
balance: z.number().min(0).optional(), 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) { for (const inv of user.fundInvestments) {
await prisma.hedgeFund.update({ const fund = await prisma.hedgeFund.findUnique({
where: { id: inv.fundId }, 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 } }) await prisma.user.delete({ where: { id: params.userId } })
+30 -3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { calcFundNav } from '@/lib/pricing'
const USERNAME_RE = /^[a-z0-9_]{3,20}$/ // validated after toLowerCase 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) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (user.isFund) return NextResponse.json({ error: 'Fund accounts cannot be self-deleted.' }, { status: 400 }) 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) { for (const inv of user.fundInvestments) {
await prisma.hedgeFund.update({ const fund = await prisma.hedgeFund.findUnique({
where: { id: inv.fundId }, 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 } }) await prisma.user.delete({ where: { id: session.user.id } })
+28 -9
View File
@@ -3,7 +3,7 @@
import Link from 'next/link' import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react' import { useSession, signOut } from 'next-auth/react'
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-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 { useRouter } from 'next/navigation'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { normalizeTag } 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 // Lazy balance fetcher so the navbar always shows current value
function BalanceBadge({ userId }: { userId: string }) { function BalanceBadge({ userId }: { userId: string }) {
// We read balance from the API to stay fresh; use SWR-style approach
const [balance, setBalance] = useState<number | null>(null) const [balance, setBalance] = useState<number | null>(null)
// One-shot fetch on mount useEffect(() => {
if (typeof window !== 'undefined' && balance === null) { let cancelled = false
fetch('/api/user/me')
.then((r) => r.json()) function fetchBalance() {
.then((d) => setBalance(d.balance ?? null)) fetch('/api/user/me')
.catch(() => {}) .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 if (balance === null) return null