From 14d79acc63758c6ae9f4127902f1c1803af2c6eb Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Fri, 20 Mar 2026 16:18:06 -0400 Subject: [PATCH] fix: implement rounding for monetary values across fund and user transactions --- src/app/api/admin/funds/[fundId]/route.ts | 8 ++++---- src/app/api/admin/users/[userId]/reset/route.ts | 4 ++-- src/app/api/admin/users/[userId]/route.ts | 8 ++++---- src/app/api/funds/[slug]/invest/route.ts | 4 ++-- src/app/api/funds/[slug]/redeem/route.ts | 4 ++-- src/app/api/lottery/pick/route.ts | 3 ++- src/app/api/trade/route.ts | 4 ++-- src/app/api/user/me/reset/route.ts | 4 ++-- src/app/api/user/me/route.ts | 6 +++--- src/lib/pricing.ts | 3 +++ src/worker/index.ts | 2 +- 11 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/app/api/admin/funds/[fundId]/route.ts b/src/app/api/admin/funds/[fundId]/route.ts index a5b6221..af10a4d 100644 --- a/src/app/api/admin/funds/[fundId]/route.ts +++ b/src/app/api/admin/funds/[fundId]/route.ts @@ -3,7 +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' +import { calcFundNav, round2 } from '@/lib/pricing' const patchSchema = z.object({ addManagerUsername: z.string().optional(), @@ -58,7 +58,7 @@ export async function PATCH( } 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({ @@ -112,14 +112,14 @@ export async function DELETE( 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 + : (2 * p.avgBuyPrice - p.hashtag.currentPrice) * 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) + const payout = Math.max(0, round2(inv.shares * nav)) if (payout > 0) { await prisma.user.update({ where: { id: inv.userId }, data: { balance: { increment: payout } } }) } diff --git a/src/app/api/admin/users/[userId]/reset/route.ts b/src/app/api/admin/users/[userId]/reset/route.ts index 280a1fd..1c53837 100644 --- a/src/app/api/admin/users/[userId]/reset/route.ts +++ b/src/app/api/admin/users/[userId]/reset/route.ts @@ -2,7 +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' +import { calcFundNav, round2 } from '@/lib/pricing' const STARTING_BALANCE = 2000 @@ -106,7 +106,7 @@ export async function POST( }, 0) const fundTotalValue = inv.fund.user.balance + fundPortfolioValue 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 [ prisma.hedgeFund.update({ where: { id: inv.fundId }, diff --git a/src/app/api/admin/users/[userId]/route.ts b/src/app/api/admin/users/[userId]/route.ts index 4b1db88..b2325eb 100644 --- a/src/app/api/admin/users/[userId]/route.ts +++ b/src/app/api/admin/users/[userId]/route.ts @@ -3,7 +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' +import { calcFundNav, round2 } from '@/lib/pricing' const schema = z.object({ balance: z.number().min(0).optional(), @@ -92,13 +92,13 @@ export async function DELETE( 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 + : (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares return sum + val }, 0) 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([ - 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 } } }), ]) } diff --git a/src/app/api/funds/[slug]/invest/route.ts b/src/app/api/funds/[slug]/invest/route.ts index 1beff46..43ae891 100644 --- a/src/app/api/funds/[slug]/invest/route.ts +++ b/src/app/api/funds/[slug]/invest/route.ts @@ -2,7 +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' +import { calcFundNav, round2 } from '@/lib/pricing' export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { 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 body = await req.json() - const amount = Number(body.amount) + const amount = round2(Number(body.amount)) if (!amount || amount < 1) { return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 }) diff --git a/src/app/api/funds/[slug]/redeem/route.ts b/src/app/api/funds/[slug]/redeem/route.ts index 05d5c06..cc9955e 100644 --- a/src/app/api/funds/[slug]/redeem/route.ts +++ b/src/app/api/funds/[slug]/redeem/route.ts @@ -2,7 +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' +import { calcFundNav, round2 } from '@/lib/pricing' export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { const session = await getServerSession(authOptions) @@ -50,7 +50,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin }, 0) const totalValue = fund.user.balance + portfolioValue const nav = calcFundNav(totalValue, fund.sharesOutstanding) - const payout = sharesToRedeem * nav + const payout = round2(sharesToRedeem * nav) if (fund.user.balance < payout) { return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 }) diff --git a/src/app/api/lottery/pick/route.ts b/src/app/api/lottery/pick/route.ts index bec2a13..84e081e 100644 --- a/src/app/api/lottery/pick/route.ts +++ b/src/app/api/lottery/pick/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 { round2 } from '@/lib/pricing' import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery' function buildPrizes(): number[] { @@ -68,7 +69,7 @@ export async function POST(req: NextRequest) { prisma.user.update({ where: { id: user.id }, data: { - balance: { increment: winAmount }, + balance: { increment: round2(winAmount) }, lastLotteryAt: now, }, }), diff --git a/src/app/api/trade/route.ts b/src/app/api/trade/route.ts index 7ef0cf9..0ee1e61 100644 --- a/src/app/api/trade/route.ts +++ b/src/app/api/trade/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { calcTrade } from '@/lib/pricing' +import { calcTrade, round2 } from '@/lib/pricing' import { formatCurrency } from '@/lib/utils' import { z } from 'zod' @@ -105,7 +105,7 @@ export async function POST(req: NextRequest) { // Update user balance await tx.user.update({ where: { id: user.id }, - data: { balance: { increment: balanceDelta } }, + data: { balance: { increment: round2(balanceDelta) } }, }) // Update / create position diff --git a/src/app/api/user/me/reset/route.ts b/src/app/api/user/me/reset/route.ts index 2a3f7bc..60da705 100644 --- a/src/app/api/user/me/reset/route.ts +++ b/src/app/api/user/me/reset/route.ts @@ -2,7 +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' +import { calcFundNav, round2 } from '@/lib/pricing' const STARTING_BALANCE = 2000 @@ -108,7 +108,7 @@ export async function POST(req: NextRequest) { }, 0) const fundTotalValue = inv.fund.user.balance + fundPortfolioValue 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 [ prisma.hedgeFund.update({ where: { id: inv.fundId }, diff --git a/src/app/api/user/me/route.ts b/src/app/api/user/me/route.ts index f4ef773..d260c9b 100644 --- a/src/app/api/user/me/route.ts +++ b/src/app/api/user/me/route.ts @@ -2,7 +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' +import { calcFundNav, round2 } from '@/lib/pricing' 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 val = p.positionType === 'LONG' ? 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 }, 0) 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([ prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }), prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }), diff --git a/src/lib/pricing.ts b/src/lib/pricing.ts index bdc1b9e..cab8917 100644 --- a/src/lib/pricing.ts +++ b/src/lib/pricing.ts @@ -39,6 +39,9 @@ export function getBalanceTier(balance: number): BalanceTier { 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. */ export function calcFundNav(totalValue: number, sharesOutstanding: number): number { if (sharesOutstanding <= 0) return 1.00 diff --git a/src/worker/index.ts b/src/worker/index.ts index 51d3e30..235a21d 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -74,7 +74,7 @@ async function forceClosePositions(hashtagId: string, price: number, tag: string await prisma.$transaction([ prisma.user.update({ where: { id: pos.userId }, - data: { balance: { increment: balanceDelta } }, + data: { balance: { increment: round2(balanceDelta) } }, }), prisma.position.update({ where: { id: pos.id },