fix: implement rounding for monetary values across fund and user transactions
Build Images and Deploy / Update-PROD-Stack (push) Failing after 33s

This commit is contained in:
2026-03-20 16:18:06 -04:00
parent 1d0b160ba8
commit 14d79acc63
11 changed files with 27 additions and 23 deletions
+4 -4
View File
@@ -3,7 +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' import { calcFundNav, round2 } from '@/lib/pricing'
const patchSchema = z.object({ const patchSchema = z.object({
addManagerUsername: z.string().optional(), addManagerUsername: z.string().optional(),
@@ -58,7 +58,7 @@ export async function PATCH(
} }
if (typeof balance === 'number') { if (typeof balance === 'number') {
await prisma.user.update({ where: { id: fund.userId }, data: { balance } }) await prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(balance) } })
} }
const updated = await prisma.hedgeFund.findUnique({ const updated = await prisma.hedgeFund.findUnique({
@@ -112,14 +112,14 @@ export async function DELETE(
const portfolioValue = fund.user.positions.reduce((sum, p) => { const portfolioValue = fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG' const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice ? p.shares * p.hashtag.currentPrice
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares : (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val return sum + val
}, 0) }, 0)
const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding) const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding)
// Pay out each investor at current NAV before wiping records // Pay out each investor at current NAV before wiping records
for (const inv of fund.investments) { for (const inv of fund.investments) {
const payout = Math.max(0, inv.shares * nav) const payout = Math.max(0, round2(inv.shares * nav))
if (payout > 0) { if (payout > 0) {
await prisma.user.update({ where: { id: inv.userId }, data: { balance: { increment: payout } } }) await prisma.user.update({ where: { id: inv.userId }, data: { balance: { increment: payout } } })
} }
@@ -2,7 +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' import { calcFundNav, round2 } from '@/lib/pricing'
const STARTING_BALANCE = 2000 const STARTING_BALANCE = 2000
@@ -106,7 +106,7 @@ export async function POST(
}, 0) }, 0)
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding) const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
const payout = Math.max(0, inv.shares * nav) const payout = Math.max(0, round2(inv.shares * nav))
return [ return [
prisma.hedgeFund.update({ prisma.hedgeFund.update({
where: { id: inv.fundId }, where: { id: inv.fundId },
+4 -4
View File
@@ -3,7 +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' import { calcFundNav, round2 } from '@/lib/pricing'
const schema = z.object({ const schema = z.object({
balance: z.number().min(0).optional(), balance: z.number().min(0).optional(),
@@ -92,13 +92,13 @@ export async function DELETE(
const portfolioValue = fund.user.positions.reduce((sum, p) => { const portfolioValue = fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG' const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice ? p.shares * p.hashtag.currentPrice
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares : (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val return sum + val
}, 0) }, 0)
const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding) const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding)
const payout = inv.shares * nav const payout = round2(Math.max(0, inv.shares * nav))
await prisma.$transaction([ await prisma.$transaction([
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }), prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: round2(Math.max(0, payout)) } } }),
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }), prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }),
]) ])
} }
+2 -2
View File
@@ -2,7 +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' import { calcFundNav, round2 } from '@/lib/pricing'
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
@@ -10,7 +10,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
const slug = decodeURIComponent(params.slug).toLowerCase() const slug = decodeURIComponent(params.slug).toLowerCase()
const body = await req.json() const body = await req.json()
const amount = Number(body.amount) const amount = round2(Number(body.amount))
if (!amount || amount < 1) { if (!amount || amount < 1) {
return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 }) return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 })
+2 -2
View File
@@ -2,7 +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' import { calcFundNav, round2 } from '@/lib/pricing'
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
@@ -50,7 +50,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
}, 0) }, 0)
const totalValue = fund.user.balance + portfolioValue const totalValue = fund.user.balance + portfolioValue
const nav = calcFundNav(totalValue, fund.sharesOutstanding) const nav = calcFundNav(totalValue, fund.sharesOutstanding)
const payout = sharesToRedeem * nav const payout = round2(sharesToRedeem * nav)
if (fund.user.balance < payout) { if (fund.user.balance < payout) {
return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 }) return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 })
+2 -1
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 { round2 } from '@/lib/pricing'
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery' import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
function buildPrizes(): number[] { function buildPrizes(): number[] {
@@ -68,7 +69,7 @@ export async function POST(req: NextRequest) {
prisma.user.update({ prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
balance: { increment: winAmount }, balance: { increment: round2(winAmount) },
lastLotteryAt: now, lastLotteryAt: now,
}, },
}), }),
+2 -2
View File
@@ -2,7 +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 { calcTrade } from '@/lib/pricing' import { calcTrade, round2 } from '@/lib/pricing'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { z } from 'zod' import { z } from 'zod'
@@ -105,7 +105,7 @@ export async function POST(req: NextRequest) {
// Update user balance // Update user balance
await tx.user.update({ await tx.user.update({
where: { id: user.id }, where: { id: user.id },
data: { balance: { increment: balanceDelta } }, data: { balance: { increment: round2(balanceDelta) } },
}) })
// Update / create position // Update / create position
+2 -2
View File
@@ -2,7 +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' import { calcFundNav, round2 } from '@/lib/pricing'
const STARTING_BALANCE = 2000 const STARTING_BALANCE = 2000
@@ -108,7 +108,7 @@ export async function POST(req: NextRequest) {
}, 0) }, 0)
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding) const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
const payout = Math.max(0, inv.shares * nav) const payout = Math.max(0, round2(inv.shares * nav))
return [ return [
prisma.hedgeFund.update({ prisma.hedgeFund.update({
where: { id: inv.fundId }, where: { id: inv.fundId },
+3 -3
View File
@@ -2,7 +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' import { calcFundNav, round2 } 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
@@ -135,11 +135,11 @@ export async function DELETE() {
const portfolioValue = fund.user.positions.reduce((sum, p) => { const portfolioValue = fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG' const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice ? p.shares * p.hashtag.currentPrice
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares : (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val return sum + val
}, 0) }, 0)
const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding) const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding)
const payout = inv.shares * nav const payout = round2(Math.max(0, inv.shares * nav))
await prisma.$transaction([ await prisma.$transaction([
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }), prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }),
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }), prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }),
+3
View File
@@ -39,6 +39,9 @@ export function getBalanceTier(balance: number): BalanceTier {
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 } return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
} }
/** Round a dollar amount to 2 decimal places for DB storage. */
export const round2 = (n: number) => Math.round(n * 100) / 100
/** Calculate NAV (net asset value) per fund share. Returns 1.00 if no shares outstanding. */ /** Calculate NAV (net asset value) per fund share. Returns 1.00 if no shares outstanding. */
export function calcFundNav(totalValue: number, sharesOutstanding: number): number { export function calcFundNav(totalValue: number, sharesOutstanding: number): number {
if (sharesOutstanding <= 0) return 1.00 if (sharesOutstanding <= 0) return 1.00
+1 -1
View File
@@ -74,7 +74,7 @@ async function forceClosePositions(hashtagId: string, price: number, tag: string
await prisma.$transaction([ await prisma.$transaction([
prisma.user.update({ prisma.user.update({
where: { id: pos.userId }, where: { id: pos.userId },
data: { balance: { increment: balanceDelta } }, data: { balance: { increment: round2(balanceDelta) } },
}), }),
prisma.position.update({ prisma.position.update({
where: { id: pos.id }, where: { id: pos.id },