feat: implement NAV calculation and payout for fund investments on user deletion
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
This commit is contained in:
@@ -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 } })
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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<number | null>(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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user