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 { 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 } })
|
||||||
|
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user