diff --git a/package.json b/package.json index 726eaf11..f6b3a5e9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "postinstall": "prisma generate", "generate": "prisma generate", "lint": "next lint", + "test": "tsx --test src/tests/*.test.ts", "start": "sleep 3 && pnpm prisma:prod && next start", "start-with-latest-migrations": "prisma migrate deploy && next start", "d": "pnpm dx && pnpm dev", @@ -105,4 +106,4 @@ "seed": "tsx prisma/seed.ts" }, "packageManager": "pnpm@8.9.2" -} \ No newline at end of file +} diff --git a/prisma/migrations/20260726000000_add_settlement_allocations/migration.sql b/prisma/migrations/20260726000000_add_settlement_allocations/migration.sql new file mode 100644 index 00000000..c22660a6 --- /dev/null +++ b/prisma/migrations/20260726000000_add_settlement_allocations/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "Expense" ADD COLUMN "settlementAllocationVersion" INTEGER; + +-- CreateTable +CREATE TABLE "SettlementAllocation" ( + "settlementExpenseId" TEXT NOT NULL, + "groupId" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "friendId" INTEGER NOT NULL, + "amount" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SettlementAllocation_pkey" PRIMARY KEY ("settlementExpenseId", "groupId", "currency", "friendId", "userId") +); + +-- CreateIndex +CREATE INDEX "SettlementAllocation_groupId_currency_friendId_userId_idx" ON "SettlementAllocation"("groupId", "currency", "friendId", "userId"); + +-- AddForeignKey +-- groupId intentionally has no foreign key so deleting a settled group keeps settlement provenance. +ALTER TABLE "SettlementAllocation" ADD CONSTRAINT "SettlementAllocation_settlementExpenseId_fkey" FOREIGN KEY ("settlementExpenseId") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aa8912fb..1afb9529 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,6 +151,7 @@ model Expense { deletedAt DateTime? deletedBy Int? updatedBy Int? + settlementAllocationVersion Int? group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade) addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade) @@ -158,11 +159,27 @@ model Expense { updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull) expenseParticipants ExpenseParticipant[] expenseNotes ExpenseNote[] + settlementAllocations SettlementAllocation[] @@index([groupId]) @@index([paidBy]) } +model SettlementAllocation { + settlementExpenseId String + /// Kept without a relation so deleting a settled group does not erase settlement provenance. + groupId Int + currency String + userId Int + friendId Int + amount Int + createdAt DateTime @default(now()) + settlementExpense Expense @relation(fields: [settlementExpenseId], references: [id], onDelete: Cascade) + + @@id([settlementExpenseId, groupId, currency, friendId, userId]) + @@index([groupId, currency, friendId, userId]) +} + model ExpenseParticipant { expenseId String userId Int diff --git a/src/pages/add.tsx b/src/pages/add.tsx index d368cf2d..a270d986 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -8,7 +8,7 @@ import { isStorageConfigured } from '~/server/storage'; import { calculateSplitShareBasedOnAmount, useAddExpenseStore } from '~/store/addStore'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; -import { toFixedNumber, toInteger } from '~/utils/numbers'; +import { toFixedNumber } from '~/utils/numbers'; // 🧾 @@ -16,18 +16,7 @@ const AddPage: NextPageWithUser<{ isStorageConfigured: boolean; enableSendingInvites: boolean; }> = ({ user, isStorageConfigured, enableSendingInvites }) => { - const { - setCurrentUser, - setGroup, - setParticipants, - setCurrency, - setAmount, - setDescription, - setPaidBy, - setAmountStr, - setSplitType, - setExpenseDate, - } = useAddExpenseStore((s) => s.actions); + const { setCurrentUser, setGroup, setParticipants } = useAddExpenseStore((s) => s.actions); const currentUser = useAddExpenseStore((s) => s.currentUser); useEffect(() => { @@ -86,40 +75,32 @@ const AddPage: NextPageWithUser<{ useEffect(() => { if (_expenseId && expenseQuery.data) { - console.log( - 'expenseQuery.data 123', - expenseQuery.data.expenseParticipants, + const amount = toFixedNumber(expenseQuery.data.amount); + const participants = calculateSplitShareBasedOnAmount( + amount, + expenseQuery.data.expenseParticipants.map((participant) => ({ + ...participant.user, + amount: toFixedNumber(participant.amount), + })), expenseQuery.data.splitType, - calculateSplitShareBasedOnAmount( - toFixedNumber(expenseQuery.data.amount), - expenseQuery.data.expenseParticipants.map((ep) => ({ - ...ep.user, - amount: toFixedNumber(ep.amount), - })), - expenseQuery.data.splitType, - expenseQuery.data.paidByUser, - ), + expenseQuery.data.paidByUser, ); - expenseQuery.data.group && setGroup(expenseQuery.data.group); - setParticipants( - calculateSplitShareBasedOnAmount( - toFixedNumber(expenseQuery.data.amount), - expenseQuery.data.expenseParticipants.map((ep) => ({ - ...ep.user, - amount: toFixedNumber(ep.amount), - })), - expenseQuery.data.splitType, - expenseQuery.data.paidByUser, - ), - ); - setCurrency(expenseQuery.data.currency); - setAmountStr(toFixedNumber(expenseQuery.data.amount).toString()); - setDescription(expenseQuery.data.name); - setPaidBy(expenseQuery.data.paidByUser); - setAmount(toFixedNumber(expenseQuery.data.amount)); - setSplitType(expenseQuery.data.splitType); - useAddExpenseStore.setState({ showFriends: false }); - setExpenseDate(expenseQuery.data.expenseDate); + + useAddExpenseStore.setState({ + amount, + amountStr: amount.toString(), + canSplitScreenClosed: true, + category: expenseQuery.data.category, + currency: expenseQuery.data.currency, + description: expenseQuery.data.name, + expenseDate: expenseQuery.data.expenseDate, + fileKey: expenseQuery.data.fileKey ?? undefined, + group: expenseQuery.data.group ?? undefined, + paidBy: expenseQuery.data.paidByUser, + participants, + showFriends: false, + splitType: expenseQuery.data.splitType, + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [_expenseId, expenseQuery.data]); diff --git a/src/pages/groups.tsx b/src/pages/groups.tsx index cf1bf884..626cf899 100644 --- a/src/pages/groups.tsx +++ b/src/pages/groups.tsx @@ -12,6 +12,7 @@ import { GroupAvatar } from '~/components/ui/avatar'; import { toUIString } from '~/utils/numbers'; import { motion } from 'framer-motion'; import { type NextPageWithUser } from '~/types'; +import { getGroupBalanceSummary } from '~/utils/balances'; const BalancePage: NextPageWithUser = () => { const groupQuery = api.group.getAllGroupsWithBalances.useQuery(); @@ -47,9 +48,10 @@ const BalancePage: NextPageWithUser = () => { ) : ( groupQuery.data?.map((g) => { - const [amount, currency] = Object.keys(g.balances).length - ? [Object.values(g.balances)[0] ?? 0, Object.keys(g.balances)[0] ?? 'USD'] - : [0, 'USD']; + const { amount, currency, multiCurrency } = getGroupBalanceSummary( + g.balances, + g.defaultCurrency, + ); return ( { amount={amount} isPositive={amount >= 0 ? true : false} currency={currency} + multiCurrency={multiCurrency} /> ); }) @@ -75,7 +78,8 @@ const GroupBalance: React.FC<{ amount: number; isPositive: boolean; currency: string; -}> = ({ name, amount, isPositive, currency, groupId }) => { + multiCurrency: boolean; +}> = ({ name, amount, isPositive, currency, groupId, multiCurrency }) => { return (
@@ -98,6 +102,7 @@ const GroupBalance: React.FC<{
{currency} {toUIString(amount)} + {multiCurrency ? '*' : ''}
)} @@ -109,4 +114,4 @@ const GroupBalance: React.FC<{ BalancePage.auth = true; -export default BalancePage; \ No newline at end of file +export default BalancePage; diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts index 228ce62f..b07e5593 100644 --- a/src/server/api/routers/group.ts +++ b/src/server/api/routers/group.ts @@ -1,7 +1,9 @@ import { SplitType } from '@prisma/client'; import { z } from 'zod'; +import { getGroupTotalsWhere } from '~/utils/balances'; import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; +import { runBalanceTransaction } from '../services/balanceTransaction'; import { createGroupExpense, deleteExpense, editExpense } from '../services/splitService'; import { TRPCError } from '@trpc/server'; import { nanoid } from 'nanoid'; @@ -262,10 +264,7 @@ export const groupRouter = createTRPCRouter({ _sum: { amount: true, }, - where: { - groupId: input.groupId, - deletedAt: null, - }, + where: getGroupTotalsWhere(input.groupId), }); return totals; @@ -285,10 +284,9 @@ export const groupRouter = createTRPCRouter({ return groupUsers; }), - leaveGroup: groupProcedure - .input(z.object({ groupId: z.number() })) - .mutation(async ({ input, ctx }) => { - const nonZeroBalance = await ctx.db.groupBalance.findFirst({ + leaveGroup: groupProcedure.input(z.object({ groupId: z.number() })).mutation(({ input, ctx }) => + runBalanceTransaction(async (tx) => { + const nonZeroBalance = await tx.groupBalance.findFirst({ where: { groupId: input.groupId, userId: ctx.session.user.id, @@ -305,7 +303,7 @@ export const groupRouter = createTRPCRouter({ }); } - const groupUser = await ctx.db.groupUser.delete({ + return tx.groupUser.delete({ where: { groupId_userId: { groupId: input.groupId, @@ -313,14 +311,12 @@ export const groupRouter = createTRPCRouter({ }, }, }); - - return groupUser; }), + ), - delete: groupProcedure - .input(z.object({ groupId: z.number() })) - .mutation(async ({ input, ctx }) => { - const group = await ctx.db.group.findUnique({ + delete: groupProcedure.input(z.object({ groupId: z.number() })).mutation(({ input, ctx }) => + runBalanceTransaction(async (tx) => { + const group = await tx.group.findUnique({ where: { id: input.groupId, }, @@ -330,10 +326,13 @@ export const groupRouter = createTRPCRouter({ }); if (group?.userId !== ctx.session.user.id) { - throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Only creator can delete the group' }); + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Only creator can delete the group', + }); } - const balanceWithNonZero = group?.groupBalances.find((b) => b.amount !== 0); + const balanceWithNonZero = group.groupBalances.find((balance) => 0 !== balance.amount); if (balanceWithNonZero) { throw new TRPCError({ @@ -342,7 +341,7 @@ export const groupRouter = createTRPCRouter({ }); } - await ctx.db.group.delete({ + await tx.group.delete({ where: { id: input.groupId, }, @@ -350,4 +349,5 @@ export const groupRouter = createTRPCRouter({ return group; }), + ), }); diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index bffdd094..3ecba29e 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -20,6 +20,7 @@ import { pushNotification } from '~/server/notification'; import { toFixedNumber, toUIString } from '~/utils/numbers'; import { SplitwiseGroupSchema, SplitwiseUserSchema } from '~/types'; import { env } from '~/env'; +import { runBalanceTransaction } from '../services/balanceTransaction'; export const userRouter = createTRPCRouter({ me: protectedProcedure.query(async ({ ctx }) => { @@ -463,38 +464,37 @@ export const userRouter = createTRPCRouter({ deleteFriend: protectedProcedure .input(z.object({ friendId: z.number() })) - .mutation(async ({ input, ctx }) => { - const friendBalances = await db.balance.findMany({ - where: { - userId: ctx.session.user.id, - friendId: input.friendId, - amount: { - not: 0, + .mutation(({ input, ctx }) => + runBalanceTransaction(async (tx) => { + const friendBalances = await tx.balance.findMany({ + where: { + OR: [ + { userId: ctx.session.user.id, friendId: input.friendId }, + { userId: input.friendId, friendId: ctx.session.user.id }, + ], + amount: { + not: 0, + }, }, - }, - }); - - if (friendBalances.length > 0) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'You have outstanding balances with this friend', }); - } - await db.balance.deleteMany({ - where: { - userId: input.friendId, - friendId: ctx.session.user.id, - }, - }); + if (friendBalances.length > 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'You have outstanding balances with this friend', + }); + } - await db.balance.deleteMany({ - where: { - friendId: input.friendId, - userId: ctx.session.user.id, - }, - }); - }), + await tx.balance.deleteMany({ + where: { + OR: [ + { userId: ctx.session.user.id, friendId: input.friendId }, + { userId: input.friendId, friendId: ctx.session.user.id }, + ], + }, + }); + }), + ), downloadData: protectedProcedure.mutation(async ({ ctx }) => { const user = ctx.session.user; diff --git a/src/server/api/services/balanceProjection.ts b/src/server/api/services/balanceProjection.ts new file mode 100644 index 00000000..a88a9cf6 --- /dev/null +++ b/src/server/api/services/balanceProjection.ts @@ -0,0 +1,144 @@ +import { type Prisma } from '@prisma/client'; + +type TransactionClient = Prisma.TransactionClient; + +const getCandidateFriendIds = (userId: number, friendIds: number[]) => [ + ...new Set(friendIds.filter((friendId) => friendId !== userId)), +]; + +export const captureSettlementAllocations = async ( + tx: TransactionClient, + settlementExpenseId: string, + userId: number, + friendIds: number[], + currency: string, +) => { + const candidateFriendIds = getCandidateFriendIds(userId, friendIds); + if (!candidateFriendIds.length) { + return; + } + + const balances = await tx.balance.findMany({ + where: { + currency, + OR: [ + { userId, friendId: { in: candidateFriendIds } }, + { userId: { in: candidateFriendIds }, friendId: userId }, + ], + }, + }); + const balanceAmounts = new Map( + balances.map((balance) => [`${balance.userId}:${balance.friendId}`, balance.amount]), + ); + const settledFriendIds = candidateFriendIds.filter( + (friendId) => + 0 === balanceAmounts.get(`${userId}:${friendId}`) && + 0 === balanceAmounts.get(`${friendId}:${userId}`), + ); + + if (!settledFriendIds.length) { + return; + } + + const groupBalances = await tx.groupBalance.findMany({ + where: { + amount: { not: 0 }, + currency, + OR: [ + { userId, firendId: { in: settledFriendIds } }, + { userId: { in: settledFriendIds }, firendId: userId }, + ], + }, + }); + + if (!groupBalances.length) { + return; + } + + await tx.settlementAllocation.createMany({ + data: groupBalances.map((balance) => ({ + settlementExpenseId, + groupId: balance.groupId, + currency: balance.currency, + userId: balance.userId, + friendId: balance.firendId, + amount: balance.amount, + })), + }); + + await Promise.all( + groupBalances.map((balance) => + tx.groupBalance.update({ + where: { + groupId_currency_firendId_userId: { + groupId: balance.groupId, + currency: balance.currency, + userId: balance.userId, + firendId: balance.firendId, + }, + }, + data: { amount: 0 }, + }), + ), + ); +}; + +export const restoreSettlementAllocations = async ( + tx: TransactionClient, + settlementExpenseId: string, +) => { + const allocations = await tx.settlementAllocation.findMany({ + where: { settlementExpenseId }, + }); + + if (!allocations.length) { + return; + } + + const requiredMemberships = new Map(); + allocations.forEach((allocation) => { + requiredMemberships.set(`${allocation.groupId}:${allocation.userId}`, { + groupId: allocation.groupId, + userId: allocation.userId, + }); + requiredMemberships.set(`${allocation.groupId}:${allocation.friendId}`, { + groupId: allocation.groupId, + userId: allocation.friendId, + }); + }); + const groupUsers = await tx.groupUser.findMany({ + where: { OR: [...requiredMemberships.values()] }, + }); + const currentMemberships = new Set( + groupUsers.map((groupUser) => `${groupUser.groupId}:${groupUser.userId}`), + ); + const restorableAllocations = allocations.filter( + (allocation) => + currentMemberships.has(`${allocation.groupId}:${allocation.userId}`) && + currentMemberships.has(`${allocation.groupId}:${allocation.friendId}`), + ); + + await Promise.all( + restorableAllocations.map((allocation) => + tx.groupBalance.upsert({ + where: { + groupId_currency_firendId_userId: { + groupId: allocation.groupId, + currency: allocation.currency, + userId: allocation.userId, + firendId: allocation.friendId, + }, + }, + create: { + groupId: allocation.groupId, + currency: allocation.currency, + userId: allocation.userId, + firendId: allocation.friendId, + amount: allocation.amount, + }, + update: { amount: { increment: allocation.amount } }, + }), + ), + ); + await tx.settlementAllocation.deleteMany({ where: { settlementExpenseId } }); +}; diff --git a/src/server/api/services/balanceTransaction.ts b/src/server/api/services/balanceTransaction.ts new file mode 100644 index 00000000..8be8666c --- /dev/null +++ b/src/server/api/services/balanceTransaction.ts @@ -0,0 +1,28 @@ +import { Prisma } from '@prisma/client'; + +import { db } from '~/server/db'; + +const MAX_TRANSACTION_ATTEMPTS = 3; + +export const runBalanceTransaction = async ( + operation: (tx: Prisma.TransactionClient) => Promise, + attempt = 1, +): Promise => { + try { + return await db.$transaction(operation, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }); + } catch (error) { + const canRetry = + error instanceof Prisma.PrismaClientKnownRequestError && + 'P2034' === error.code && + attempt < MAX_TRANSACTION_ATTEMPTS; + + if (!canRetry) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, attempt * 20)); + return runBalanceTransaction(operation, attempt + 1); + } +}; diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 66fbd214..f379ea3a 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -1,10 +1,123 @@ -import { type Expense, type SplitType, type User } from '@prisma/client'; +import { SplitType, type Expense, type Prisma, type User } from '@prisma/client'; import { nanoid } from 'nanoid'; import { db } from '~/server/db'; import { type SplitwiseGroup, type SplitwiseUser } from '~/types'; import { toFixedNumber, toInteger } from '~/utils/numbers'; +import { toBalancedParticipants } from '~/utils/splits'; +import { captureSettlementAllocations, restoreSettlementAllocations } from './balanceProjection'; +import { runBalanceTransaction } from './balanceTransaction'; import { sendExpensePushNotification } from './notificationService'; +type StoredParticipant = { userId: number; amount: number }; + +const SETTLEMENT_ALLOCATION_VERSION = 1; + +const updatePersonalBalances = async ( + tx: Prisma.TransactionClient, + paidBy: number, + currency: string, + participants: StoredParticipant[], + direction: 1 | -1, +) => { + const otherParticipants = participants + .filter((participant) => participant.userId !== paidBy) + .sort((a, b) => a.userId - b.userId); + + await Promise.all( + otherParticipants.flatMap((participant) => { + const payerAmount = -participant.amount * direction; + const participantAmount = participant.amount * direction; + + return [ + tx.balance.upsert({ + where: { + userId_currency_friendId: { + userId: paidBy, + currency, + friendId: participant.userId, + }, + }, + create: { + userId: paidBy, + currency, + friendId: participant.userId, + amount: payerAmount, + }, + update: { amount: { increment: payerAmount } }, + }), + tx.balance.upsert({ + where: { + userId_currency_friendId: { + userId: participant.userId, + currency, + friendId: paidBy, + }, + }, + create: { + userId: participant.userId, + currency, + friendId: paidBy, + amount: participantAmount, + }, + update: { amount: { increment: participantAmount } }, + }), + ]; + }), + ); +}; + +const assertSettlementCanBeChanged = (expense: { + groupId: number | null; + settlementAllocationVersion: number | null; + splitType: SplitType; +}) => { + if ( + null === expense.groupId && + SplitType.SETTLEMENT === expense.splitType && + SETTLEMENT_ALLOCATION_VERSION !== expense.settlementAllocationVersion + ) { + throw new Error('Legacy settlements cannot be edited or deleted safely'); + } +}; + +const toStoredParticipants = (participants: { userId: number; amount: number }[]) => + participants.map((participant) => ({ + userId: participant.userId, + amount: toInteger(participant.amount), + })); + +const hasSameFinancialEffect = ( + expense: { + amount: number; + currency: string; + paidBy: number; + splitType: SplitType; + expenseParticipants: StoredParticipant[]; + }, + amount: number, + currency: string, + paidBy: number, + splitType: SplitType, + participants: StoredParticipant[], +) => { + if ( + expense.amount !== toInteger(amount) || + expense.currency !== currency || + expense.paidBy !== paidBy || + expense.splitType !== splitType || + expense.expenseParticipants.length !== participants.length + ) { + return false; + } + + const oldAmounts = new Map( + expense.expenseParticipants.map((participant) => [participant.userId, participant.amount]), + ); + return participants.every( + (participant) => oldAmounts.get(participant.userId) === participant.amount, + ); +}; + export async function joinGroup(userId: number, publicGroupId: string) { const group = await db.group.findUnique({ where: { @@ -26,170 +139,188 @@ export async function joinGroup(userId: number, publicGroupId: string) { return group; } -export async function createGroupExpense( - groupId: number, - paidBy: number, - name: string, - category: string, - amount: number, - splitType: SplitType, - currency: string, - participants: { userId: number; amount: number }[], - currentUserId: number, - expenseDate: Date, - fileKey?: string, -) { - const operations = []; - - const modifiedAmount = toInteger(amount); - - // Create expense operation - operations.push( - db.expense.create({ +const createPersonalExpense = async ({ + paidBy, + name, + category, + amount, + splitType, + currency, + participants, + currentUserId, + expenseDate, + fileKey, +}: { + paidBy: number; + name: string; + category: string; + amount: number; + splitType: SplitType; + currency: string; + participants: { userId: number; amount: number }[]; + currentUserId: number; + expenseDate: Date; + fileKey?: string; +}) => { + const storedParticipants = toStoredParticipants(participants); + + return runBalanceTransaction(async (tx) => { + const expense = await tx.expense.create({ data: { - groupId, paidBy, name, category, - amount: modifiedAmount, + amount: toInteger(amount), splitType, currency, - expenseParticipants: { - create: participants.map((participant) => ({ - userId: participant.userId, - amount: toInteger(participant.amount), - })), - }, + expenseParticipants: { create: storedParticipants }, fileKey, addedBy: currentUserId, expenseDate, + settlementAllocationVersion: + SplitType.SETTLEMENT === splitType ? SETTLEMENT_ALLOCATION_VERSION : null, }, - }), - ); + }); - // Update group balances and overall balances operations - participants.forEach((participant) => { - if (participant.userId === paidBy) { - return; + await updatePersonalBalances(tx, paidBy, currency, storedParticipants, 1); + + if (SplitType.SETTLEMENT === splitType) { + await captureSettlementAllocations( + tx, + expense.id, + paidBy, + storedParticipants.map((participant) => participant.userId), + currency, + ); } - //participant.amount will be in negative + return expense; + }); +}; + +const editPersonalExpense = async ({ + expenseId, + paidBy, + name, + category, + amount, + splitType, + currency, + participants, + currentUserId, + expenseDate, + fileKey, +}: { + expenseId: string; + paidBy: number; + name: string; + category: string; + amount: number; + splitType: SplitType; + currency: string; + participants: { userId: number; amount: number }[]; + currentUserId: number; + expenseDate: Date; + fileKey?: string; +}) => { + const storedParticipants = toStoredParticipants(participants); + + return runBalanceTransaction(async (tx) => { + const expense = await tx.expense.findUnique({ + where: { id: expenseId }, + include: { expenseParticipants: true }, + }); - // Update balance where participant owes to the payer - operations.push( - db.groupBalance.upsert({ - where: { - groupId_currency_firendId_userId: { - groupId, - currency, - userId: paidBy, - firendId: participant.userId, - }, - }, - update: { - amount: { - increment: -toInteger(participant.amount), - }, - }, - create: { - groupId, - currency, - userId: paidBy, - firendId: participant.userId, - amount: -toInteger(participant.amount), - }, - }), - ); + if (!expense) { + throw new Error('Expense not found'); + } + if (expense.deletedAt) { + throw new Error('Deleted expenses cannot be edited'); + } - // Update balance where payer owes to the participant (opposite balance) - operations.push( - db.groupBalance.upsert({ - where: { - groupId_currency_firendId_userId: { - groupId, - currency, - firendId: paidBy, - userId: participant.userId, - }, - }, - update: { - amount: { - increment: toInteger(participant.amount), - }, - }, - create: { - groupId, - currency, - userId: participant.userId, - firendId: paidBy, - amount: toInteger(participant.amount), // Negative because it's the opposite balance - }, - }), - ); + assertSettlementCanBeChanged(expense); - // Update payer's balance towards the participant - operations.push( - db.balance.upsert({ - where: { - userId_currency_friendId: { - userId: paidBy, - currency, - friendId: participant.userId, - }, - }, - update: { - amount: { - increment: -toInteger(participant.amount), - }, - }, - create: { - userId: paidBy, - currency, - friendId: participant.userId, - amount: -toInteger(participant.amount), - }, - }), - ); + if (hasSameFinancialEffect(expense, amount, currency, paidBy, splitType, storedParticipants)) { + await tx.expense.update({ + where: { id: expenseId }, + data: { name, category, fileKey, expenseDate, updatedBy: currentUserId }, + }); + return { id: expenseId }; + } - // Update participant's balance towards the payer - operations.push( - db.balance.upsert({ - where: { - userId_currency_friendId: { - userId: participant.userId, - currency, - friendId: paidBy, - }, - }, - update: { - amount: { - increment: toInteger(participant.amount), - }, - }, - create: { - userId: participant.userId, - currency, - friendId: paidBy, - amount: toInteger(participant.amount), // Negative because it's the opposite balance - }, - }), + await restoreSettlementAllocations(tx, expenseId); + await updatePersonalBalances( + tx, + expense.paidBy, + expense.currency, + expense.expenseParticipants, + -1, ); + await tx.expenseParticipant.deleteMany({ where: { expenseId } }); + await tx.expense.update({ + where: { id: expenseId }, + data: { + paidBy, + name, + category, + amount: toInteger(amount), + splitType, + currency, + expenseParticipants: { create: storedParticipants }, + fileKey, + expenseDate, + updatedBy: currentUserId, + settlementAllocationVersion: + SplitType.SETTLEMENT === splitType ? SETTLEMENT_ALLOCATION_VERSION : null, + }, + }); + await updatePersonalBalances(tx, paidBy, currency, storedParticipants, 1); + + if (SplitType.SETTLEMENT === splitType) { + await captureSettlementAllocations( + tx, + expenseId, + paidBy, + storedParticipants.map((participant) => participant.userId), + currency, + ); + } + + return { id: expenseId }; }); +}; - // Execute all operations in a transaction - const result = await db.$transaction(operations); - await updateGroupExpenseForIfBalanceIsZero( - paidBy, - participants.map((p) => p.userId), - currency, - ); - if (result[0]) { - sendExpensePushNotification(result[0].id).catch(console.error); - } - return result[0]; -} +const deletePersonalExpense = async (expenseId: string, deletedBy: number) => + runBalanceTransaction(async (tx) => { + const expense = await tx.expense.findUnique({ + where: { id: expenseId }, + include: { expenseParticipants: true }, + }); -export async function addUserExpense( + if (!expense) { + throw new Error('Expense not found'); + } + if (expense.deletedAt) { + throw new Error('Expense is already deleted'); + } + + assertSettlementCanBeChanged(expense); + await restoreSettlementAllocations(tx, expenseId); + await updatePersonalBalances( + tx, + expense.paidBy, + expense.currency, + expense.expenseParticipants, + -1, + ); + await tx.expense.update({ + where: { id: expenseId }, + data: { deletedBy, deletedAt: new Date() }, + }); + }); + +export async function createGroupExpense( + groupId: number, paidBy: number, name: string, category: string, @@ -201,97 +332,191 @@ export async function addUserExpense( expenseDate: Date, fileKey?: string, ) { - const operations = []; - - // Create expense operation - operations.push( - db.expense.create({ - data: { - paidBy, - name, - category, - amount: toInteger(amount), - splitType, - currency, - expenseParticipants: { - create: participants.map((participant) => ({ - userId: participant.userId, - amount: toInteger(participant.amount), - })), - }, - fileKey, - addedBy: currentUserId, - expenseDate, - }, - }), - ); + const modifiedAmount = toInteger(amount); + participants = toBalancedParticipants(amount, paidBy, participants); + const expense = await runBalanceTransaction(async (tx) => { + const participantIds = participants.map((participant) => participant.userId); + const memberCount = await tx.groupUser.count({ + where: { groupId, userId: { in: participantIds } }, + }); - // Update group balances and overall balances operations - participants.forEach((participant) => { - // Update payer's balance towards the participant - if (participant.userId === paidBy) { - return; + if (memberCount !== new Set(participantIds).size) { + throw new Error('All expense participants must be current group members'); } + const operations = []; + + // Create expense operation operations.push( - db.balance.upsert({ - where: { - userId_currency_friendId: { + tx.expense.create({ + data: { + groupId, + paidBy, + name, + category, + amount: modifiedAmount, + splitType, + currency, + expenseParticipants: { + create: participants.map((participant) => ({ + userId: participant.userId, + amount: toInteger(participant.amount), + })), + }, + fileKey, + addedBy: currentUserId, + expenseDate, + }, + }), + ); + + // Update group balances and overall balances operations + participants.forEach((participant) => { + if (participant.userId === paidBy) { + return; + } + + //participant.amount will be in negative + + // Update balance where participant owes to the payer + operations.push( + tx.groupBalance.upsert({ + where: { + groupId_currency_firendId_userId: { + groupId, + currency, + userId: paidBy, + firendId: participant.userId, + }, + }, + update: { + amount: { + increment: -toInteger(participant.amount), + }, + }, + create: { + groupId, + currency, + userId: paidBy, + firendId: participant.userId, + amount: -toInteger(participant.amount), + }, + }), + ); + + // Update balance where payer owes to the participant (opposite balance) + operations.push( + tx.groupBalance.upsert({ + where: { + groupId_currency_firendId_userId: { + groupId, + currency, + firendId: paidBy, + userId: participant.userId, + }, + }, + update: { + amount: { + increment: toInteger(participant.amount), + }, + }, + create: { + groupId, + currency, + userId: participant.userId, + firendId: paidBy, + amount: toInteger(participant.amount), // Negative because it's the opposite balance + }, + }), + ); + + // Update payer's balance towards the participant + operations.push( + tx.balance.upsert({ + where: { + userId_currency_friendId: { + userId: paidBy, + currency, + friendId: participant.userId, + }, + }, + update: { + amount: { + increment: -toInteger(participant.amount), + }, + }, + create: { userId: paidBy, currency, friendId: participant.userId, + amount: -toInteger(participant.amount), }, - }, - update: { - amount: { - increment: -toInteger(participant.amount), + }), + ); + + // Update participant's balance towards the payer + operations.push( + tx.balance.upsert({ + where: { + userId_currency_friendId: { + userId: participant.userId, + currency, + friendId: paidBy, + }, + }, + update: { + amount: { + increment: toInteger(participant.amount), + }, }, - }, - create: { - userId: paidBy, - currency, - friendId: participant.userId, - amount: -toInteger(participant.amount), - }, - }), - ); - - // Update participant's balance towards the payer - operations.push( - db.balance.upsert({ - where: { - userId_currency_friendId: { + create: { userId: participant.userId, currency, friendId: paidBy, + amount: toInteger(participant.amount), // Negative because it's the opposite balance }, - }, - update: { - amount: { - increment: toInteger(participant.amount), - }, - }, - create: { - userId: participant.userId, - currency, - friendId: paidBy, - amount: toInteger(participant.amount), // Negative because it's the opposite balance - }, - }), - ); + }), + ); + }); + + const result = await Promise.all(operations); + return result[0] as Expense | undefined; }); - // Execute all operations in a transaction - const result = await db.$transaction(operations); - await updateGroupExpenseForIfBalanceIsZero( + if (expense) { + sendExpensePushNotification(expense.id).catch(console.error); + } + return expense; +} + +export async function addUserExpense( + paidBy: number, + name: string, + category: string, + amount: number, + splitType: SplitType, + currency: string, + participants: { userId: number; amount: number }[], + currentUserId: number, + expenseDate: Date, + fileKey?: string, +) { + participants = toBalancedParticipants(amount, paidBy, participants); + const expense = await createPersonalExpense({ paidBy, - participants.map((p) => p.userId), + name, + category, + amount, + splitType, currency, - ); - if (result[0]) { - sendExpensePushNotification(result[0].id).catch(console.error); - } - return result[0]; + participants, + currentUserId, + expenseDate, + fileKey, + }); + + sendExpensePushNotification(expense.id).catch(console.error); + return expense; } export async function deleteExpense(expenseId: string, deletedBy: number) { @@ -310,6 +535,12 @@ export async function deleteExpense(expenseId: string, deletedBy: number) { throw new Error('Expense not found'); } + if (null === expense.groupId) { + await deletePersonalExpense(expenseId, deletedBy); + sendExpensePushNotification(expenseId).catch(console.error); + return; + } + for (const participant of expense.expenseParticipants) { // Update payer's balance towards the participant if (participant.userId === expense.paidBy) { @@ -454,58 +685,63 @@ export async function editExpense( throw new Error('Expense not found'); } - const operations = []; + participants = toBalancedParticipants(amount, paidBy, participants); - // First reverse all existing balances - for (const participant of expense.expenseParticipants) { - if (participant.userId === expense.paidBy) { - continue; + if (null === expense.groupId) { + const result = await editPersonalExpense({ + expenseId, + paidBy, + name, + category, + amount, + splitType, + currency, + participants, + currentUserId, + expenseDate, + fileKey, + }); + sendExpensePushNotification(expenseId).catch(console.error); + return result; + } + + const result = await runBalanceTransaction(async (tx) => { + const expense = await tx.expense.findUnique({ + where: { id: expenseId }, + include: { expenseParticipants: true }, + }); + + if (!expense || null === expense.groupId) { + throw new Error('Group expense not found'); + } + if (expense.deletedAt) { + throw new Error('Deleted expenses cannot be edited'); } - operations.push( - db.balance.update({ - where: { - userId_currency_friendId: { - userId: expense.paidBy, - currency: expense.currency, - friendId: participant.userId, - }, - }, - data: { - amount: { - increment: participant.amount, - }, - }, - }), - ); + const participantIds = participants.map((participant) => participant.userId); + const memberCount = await tx.groupUser.count({ + where: { groupId: expense.groupId, userId: { in: participantIds } }, + }); - operations.push( - db.balance.update({ - where: { - userId_currency_friendId: { - userId: participant.userId, - currency: expense.currency, - friendId: expense.paidBy, - }, - }, - data: { - amount: { - decrement: participant.amount, - }, - }, - }), - ); + if (memberCount !== new Set(participantIds).size) { + throw new Error('All expense participants must be current group members'); + } + + const operations = []; + + // First reverse all existing balances + for (const participant of expense.expenseParticipants) { + if (participant.userId === expense.paidBy) { + continue; + } - // Reverse group balances if it's a group expense - if (expense.groupId) { operations.push( - db.groupBalance.update({ + tx.balance.update({ where: { - groupId_currency_firendId_userId: { - groupId: expense.groupId, - currency: expense.currency, + userId_currency_friendId: { userId: expense.paidBy, - firendId: participant.userId, + currency: expense.currency, + friendId: participant.userId, }, }, data: { @@ -517,13 +753,12 @@ export async function editExpense( ); operations.push( - db.groupBalance.update({ + tx.balance.update({ where: { - groupId_currency_firendId_userId: { - groupId: expense.groupId, - currency: expense.currency, + userId_currency_friendId: { userId: participant.userId, - firendId: expense.paidBy, + currency: expense.currency, + friendId: expense.paidBy, }, }, data: { @@ -533,112 +768,100 @@ export async function editExpense( }, }), ); - } - } - - // Delete existing participants - operations.push( - db.expenseParticipant.deleteMany({ - where: { - expenseId, - }, - }), - ); - - // Update expense with new details and create new participants - operations.push( - db.expense.update({ - where: { id: expenseId }, - data: { - paidBy, - name, - category, - amount: toInteger(amount), - splitType, - currency, - expenseParticipants: { - create: participants.map((participant) => ({ - userId: participant.userId, - amount: toInteger(participant.amount), - })), - }, - fileKey, - expenseDate, - updatedBy: currentUserId, - }, - }), - ); - // Add new balances - participants.forEach((participant) => { - if (participant.userId === paidBy) { - return; + // Reverse group balances if it's a group expense + if (expense.groupId) { + operations.push( + tx.groupBalance.update({ + where: { + groupId_currency_firendId_userId: { + groupId: expense.groupId, + currency: expense.currency, + userId: expense.paidBy, + firendId: participant.userId, + }, + }, + data: { + amount: { + increment: participant.amount, + }, + }, + }), + ); + + operations.push( + tx.groupBalance.update({ + where: { + groupId_currency_firendId_userId: { + groupId: expense.groupId, + currency: expense.currency, + userId: participant.userId, + firendId: expense.paidBy, + }, + }, + data: { + amount: { + decrement: participant.amount, + }, + }, + }), + ); + } } + // Delete existing participants operations.push( - db.balance.upsert({ + tx.expenseParticipant.deleteMany({ where: { - userId_currency_friendId: { - userId: paidBy, - currency, - friendId: participant.userId, - }, - }, - create: { - userId: paidBy, - currency, - friendId: participant.userId, - amount: -toInteger(participant.amount), - }, - update: { - amount: { - increment: -toInteger(participant.amount), - }, + expenseId, }, }), ); + // Update expense with new details and create new participants operations.push( - db.balance.upsert({ - where: { - userId_currency_friendId: { - userId: participant.userId, - currency, - friendId: paidBy, - }, - }, - create: { - userId: participant.userId, + tx.expense.update({ + where: { id: expenseId }, + data: { + paidBy, + name, + category, + amount: toInteger(amount), + splitType, currency, - friendId: paidBy, - amount: toInteger(participant.amount), - }, - update: { - amount: { - increment: toInteger(participant.amount), + expenseParticipants: { + create: participants.map((participant) => ({ + userId: participant.userId, + amount: toInteger(participant.amount), + })), }, + fileKey, + expenseDate, + updatedBy: currentUserId, }, }), ); - // Add new group balances if it's a group expense - if (expense.groupId) { + // Add new balances + participants.forEach((participant) => { + if (participant.userId === paidBy) { + return; + } + operations.push( - db.groupBalance.upsert({ + tx.balance.upsert({ where: { - groupId_currency_firendId_userId: { - groupId: expense.groupId, - currency, + userId_currency_friendId: { userId: paidBy, - firendId: participant.userId, + currency, + friendId: participant.userId, }, }, create: { - amount: -toInteger(participant.amount), - groupId: expense.groupId, - currency, userId: paidBy, - firendId: participant.userId, + currency, + friendId: participant.userId, + amount: -toInteger(participant.amount), }, update: { amount: { @@ -649,21 +872,19 @@ export async function editExpense( ); operations.push( - db.groupBalance.upsert({ + tx.balance.upsert({ where: { - groupId_currency_firendId_userId: { - groupId: expense.groupId, - currency, + userId_currency_friendId: { userId: participant.userId, - firendId: paidBy, + currency, + friendId: paidBy, }, }, create: { - amount: toInteger(participant.amount), - groupId: expense.groupId, - currency, userId: participant.userId, - firendId: paidBy, + currency, + friendId: paidBy, + amount: toInteger(participant.amount), }, update: { amount: { @@ -672,65 +893,67 @@ export async function editExpense( }, }), ); - } - }); - await db.$transaction(operations); - await updateGroupExpenseForIfBalanceIsZero( - paidBy, - participants.map((p) => p.userId), - currency, - ); - sendExpensePushNotification(expenseId).catch(console.error); - return { id: expenseId }; // Return the updated expense -} + // Add new group balances if it's a group expense + if (expense.groupId) { + operations.push( + tx.groupBalance.upsert({ + where: { + groupId_currency_firendId_userId: { + groupId: expense.groupId, + currency, + userId: paidBy, + firendId: participant.userId, + }, + }, + create: { + amount: -toInteger(participant.amount), + groupId: expense.groupId, + currency, + userId: paidBy, + firendId: participant.userId, + }, + update: { + amount: { + increment: -toInteger(participant.amount), + }, + }, + }), + ); + + operations.push( + tx.groupBalance.upsert({ + where: { + groupId_currency_firendId_userId: { + groupId: expense.groupId, + currency, + userId: participant.userId, + firendId: paidBy, + }, + }, + create: { + amount: toInteger(participant.amount), + groupId: expense.groupId, + currency, + userId: participant.userId, + firendId: paidBy, + }, + update: { + amount: { + increment: toInteger(participant.amount), + }, + }, + }), + ); + } + }); -async function updateGroupExpenseForIfBalanceIsZero( - userId: number, - friendIds: Array, - currency: string, -) { - console.log('Checking for users with 0 balance to reflect in group'); - const balances = await db.balance.findMany({ - where: { - userId, - currency, - friendId: { - in: friendIds, - }, - amount: 0, - }, + await Promise.all(operations); + return { id: expenseId }; }); - console.log('Total balances needs to be updated:', balances.length); - - if (balances.length) { - await db.groupBalance.updateMany({ - where: { - userId, - firendId: { - in: friendIds, - }, - currency, - }, - data: { - amount: 0, - }, - }); - - await db.groupBalance.updateMany({ - where: { - userId: { - in: friendIds, - }, - firendId: userId, - currency, - }, - data: { - amount: 0, - }, - }); - } + sendExpensePushNotification(expenseId).catch(console.error); + return result; } export async function getCompleteFriendsDetails(userId: number) { diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 908a5600..622405d1 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -1,6 +1,7 @@ import { type Group, SplitType, type User } from '@prisma/client'; import Router from 'next/router'; import { create } from 'zustand'; +import { toFixedNumber, toInteger } from '~/utils/numbers'; export type Participant = User & { amount?: number; splitShare?: number }; @@ -78,9 +79,16 @@ export const useAddExpenseStore = create()((set) => ({ setAmountStr: (amountStr) => set({ amountStr }), setSplitType: (splitType) => set((state) => { + const participants = calculateSplitShareBasedOnAmount( + state.amount, + state.participants, + splitType, + state.paidBy, + ); + return { splitType, - ...calculateParticipantSplit(state.amount, state.participants, splitType, state.paidBy), + ...calculateParticipantSplit(state.amount, participants, splitType, state.paidBy), }; }), setGroup: (group) => { @@ -176,7 +184,7 @@ export const useAddExpenseStore = create()((set) => ({ amount: 0, participants: s.currentUser ? [s.currentUser] : [], description: '', - fileKey: '', + fileKey: undefined, category: 'general', splitType: SplitType.EQUAL, group: undefined, @@ -219,9 +227,10 @@ export function calculateParticipantSplit( break; case SplitType.SHARE: const totalShare = participants.reduce((acc, p) => acc + (p.splitShare ?? 0), 0); + canSplitScreenClosed = Number.isFinite(totalShare) && 0 < totalShare; updatedParticipants = participants.map((p) => ({ ...p, - amount: ((p.splitShare ?? 0) * amount) / totalShare, + amount: canSplitScreenClosed ? ((p.splitShare ?? 0) * amount) / totalShare : 0, })); break; case SplitType.EXACT: @@ -245,6 +254,11 @@ export function calculateParticipantSplit( break; } + const participantsToAdjust = updatedParticipants + .filter((participant) => 0 !== participant.amount) + .map((participant) => participant.id) + .sort((a, b) => a - b); + updatedParticipants = updatedParticipants.map((p) => { if (p.id === paidBy?.id) { return { ...p, amount: -(p.amount ?? 0) + amount }; @@ -252,6 +266,47 @@ export function calculateParticipantSplit( return { ...p, amount: -(p.amount ?? 0) }; }); + if (updatedParticipants.some((participant) => !Number.isFinite(participant.amount))) { + return { + participants: updatedParticipants.map((participant) => ({ ...participant, amount: 0 })), + canSplitScreenClosed: false, + }; + } + + if (canSplitScreenClosed && participantsToAdjust.length) { + const amounts = new Map( + updatedParticipants.map((participant) => [ + participant.id, + toInteger(participant.amount ?? 0), + ]), + ); + let imbalance = [...amounts.values()].reduce((total, participantAmount) => { + return total + participantAmount; + }, 0); + let index = 0; + + if (!Number.isFinite(imbalance)) { + return { participants: updatedParticipants, canSplitScreenClosed: false }; + } + + while (0 !== imbalance) { + const participantId = participantsToAdjust[index % participantsToAdjust.length]; + if (undefined === participantId) { + break; + } + + const correction = 0 < imbalance ? -1 : 1; + amounts.set(participantId, (amounts.get(participantId) ?? 0) + correction); + imbalance += correction; + index += 1; + } + + updatedParticipants = updatedParticipants.map((participant) => ({ + ...participant, + amount: toFixedNumber(amounts.get(participant.id) ?? 0), + })); + } + return { participants: updatedParticipants, canSplitScreenClosed }; } @@ -263,8 +318,6 @@ export function calculateSplitShareBasedOnAmount( ) { let updatedParticipants = [...participants]; - console.log('calculateSplitShareBasedOnAmount', amount, participants, splitType); - switch (splitType) { case SplitType.EQUAL: // For equal split, split share should be amount/participants or 0 if amount is 0 @@ -282,8 +335,8 @@ export function calculateSplitShareBasedOnAmount( amount === 0 ? 0 : paidBy?.id !== p.id - ? (Math.abs(p.amount ?? 0) / amount) * 100 - : (Math.abs(amount - (p.amount ?? 0)) / amount) * 100, + ? Math.round((Math.abs(p.amount ?? 0) / amount) * 10000) / 100 + : Math.round((Math.abs(amount - (p.amount ?? 0)) / amount) * 10000) / 100, })); break; diff --git a/src/tests/balanceProjection.integration.test.ts b/src/tests/balanceProjection.integration.test.ts new file mode 100644 index 00000000..e950bfc7 --- /dev/null +++ b/src/tests/balanceProjection.integration.test.ts @@ -0,0 +1,515 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +const databaseUrl = process.env.TEST_DATABASE_URL; + +void test( + 'does not zero unrelated group balances when one overall balance settles', + { skip: !databaseUrl }, + async () => { + assert.match(new URL(databaseUrl!).pathname, /test/i, 'Refusing to clean a non-test database'); + process.env.DATABASE_URL = databaseUrl; + process.env.SKIP_ENV_VALIDATION = '1'; + + const [{ SplitType }, { db }, { addUserExpense, deleteExpense }] = await Promise.all([ + import('@prisma/client'), + import('~/server/db'), + import('~/server/api/services/splitService'), + ]); + + const suffix = `${Date.now()}-${Math.random()}`; + const [userA, userB, userC] = await Promise.all([ + db.user.create({ data: { email: `a-${suffix}@example.com`, name: 'A' } }), + db.user.create({ data: { email: `b-${suffix}@example.com`, name: 'B' } }), + db.user.create({ data: { email: `c-${suffix}@example.com`, name: 'C' } }), + ]); + const [groupOne, groupTwo] = await Promise.all([ + db.group.create({ data: { name: 'One', publicId: `one-${suffix}`, userId: userB.id } }), + db.group.create({ data: { name: 'Two', publicId: `two-${suffix}`, userId: userB.id } }), + ]); + + await db.groupUser.createMany({ + data: [groupOne, groupTwo].flatMap((group) => + [userA, userB, userC].map((user) => ({ groupId: group.id, userId: user.id })), + ), + }); + await db.balance.createMany({ + data: [ + { userId: userB.id, friendId: userA.id, currency: 'USD', amount: -1000 }, + { userId: userA.id, friendId: userB.id, currency: 'USD', amount: 1000 }, + { userId: userB.id, friendId: userC.id, currency: 'USD', amount: 2000 }, + { userId: userC.id, friendId: userB.id, currency: 'USD', amount: -2000 }, + ], + }); + await db.groupBalance.createMany({ + data: [ + { + groupId: groupOne.id, + userId: userB.id, + firendId: userA.id, + currency: 'USD', + amount: -1000, + }, + { + groupId: groupOne.id, + userId: userA.id, + firendId: userB.id, + currency: 'USD', + amount: 1000, + }, + { + groupId: groupOne.id, + userId: userB.id, + firendId: userC.id, + currency: 'USD', + amount: 2000, + }, + { + groupId: groupOne.id, + userId: userC.id, + firendId: userB.id, + currency: 'USD', + amount: -2000, + }, + { + groupId: groupTwo.id, + userId: userB.id, + firendId: userC.id, + currency: 'USD', + amount: 500, + }, + { + groupId: groupTwo.id, + userId: userC.id, + firendId: userB.id, + currency: 'USD', + amount: -500, + }, + ], + }); + + const ordinaryOffset = await addUserExpense( + userB.id, + 'Personal offset', + 'general', + 10, + SplitType.EXACT, + 'USD', + [ + { userId: userB.id, amount: 10 }, + { userId: userA.id, amount: -10 }, + ], + userB.id, + new Date('2025-12-31'), + ); + const groupBalanceBeforeSettlement = await db.groupBalance.findUniqueOrThrow({ + where: { + groupId_currency_firendId_userId: { + groupId: groupOne.id, + currency: 'USD', + userId: userB.id, + firendId: userA.id, + }, + }, + }); + assert.equal(groupBalanceBeforeSettlement.amount, -1000); + await deleteExpense(ordinaryOffset.id, userB.id); + + await addUserExpense( + userB.id, + 'Settle up', + 'general', + 10, + SplitType.SETTLEMENT, + 'USD', + [ + { userId: userB.id, amount: 10 }, + { userId: userA.id, amount: -10 }, + ], + userB.id, + new Date('2026-01-01'), + ); + + const rows = await db.groupBalance.findMany({ + where: { + currency: 'USD', + OR: [{ userId: userB.id }, { firendId: userB.id }], + }, + orderBy: [{ groupId: 'asc' }, { firendId: 'asc' }], + }); + const amounts = new Map( + rows.map((row) => [`${row.groupId}:${row.userId}:${row.firendId}`, row.amount]), + ); + + assert.equal(amounts.get(`${groupOne.id}:${userB.id}:${userA.id}`), 0); + assert.equal(amounts.get(`${groupOne.id}:${userA.id}:${userB.id}`), 0); + assert.equal(amounts.get(`${groupOne.id}:${userB.id}:${userC.id}`), 2000); + assert.equal(amounts.get(`${groupOne.id}:${userC.id}:${userB.id}`), -2000); + assert.equal(amounts.get(`${groupTwo.id}:${userB.id}:${userC.id}`), 500); + assert.equal(amounts.get(`${groupTwo.id}:${userC.id}:${userB.id}`), -500); + + await db.group.deleteMany({ where: { id: { in: [groupOne.id, groupTwo.id] } } }); + await db.user.deleteMany({ where: { id: { in: [userA.id, userB.id, userC.id] } } }); + await db.$disconnect(); + }, +); + +void test( + 'restores group balances when a completed personal settlement becomes partial', + { skip: !databaseUrl }, + async () => { + assert.match(new URL(databaseUrl!).pathname, /test/i, 'Refusing to clean a non-test database'); + process.env.DATABASE_URL = databaseUrl; + process.env.SKIP_ENV_VALIDATION = '1'; + + const [ + { SplitType }, + { db }, + { addUserExpense, createGroupExpense, deleteExpense, editExpense }, + ] = await Promise.all([ + import('@prisma/client'), + import('~/server/db'), + import('~/server/api/services/splitService'), + ]); + const suffix = `${Date.now()}-${Math.random()}`; + const [debtor, creditor] = await Promise.all([ + db.user.create({ data: { email: `debtor-${suffix}@example.com`, name: 'Debtor' } }), + db.user.create({ data: { email: `creditor-${suffix}@example.com`, name: 'Creditor' } }), + ]); + const [groupOne, groupTwo] = await Promise.all([ + db.group.create({ + data: { name: 'Settlement one', publicId: `settlement-one-${suffix}`, userId: creditor.id }, + }), + db.group.create({ + data: { name: 'Settlement two', publicId: `settlement-two-${suffix}`, userId: creditor.id }, + }), + ]); + + await db.groupUser.createMany({ + data: [groupOne, groupTwo].flatMap((group) => + [debtor, creditor].map((user) => ({ groupId: group.id, userId: user.id })), + ), + }); + await db.balance.createMany({ + data: [ + { userId: debtor.id, friendId: creditor.id, currency: 'USD', amount: -500 }, + { userId: creditor.id, friendId: debtor.id, currency: 'USD', amount: 500 }, + ], + }); + await db.groupBalance.createMany({ + data: [ + { + groupId: groupOne.id, + userId: debtor.id, + firendId: creditor.id, + currency: 'USD', + amount: -300, + }, + { + groupId: groupOne.id, + userId: creditor.id, + firendId: debtor.id, + currency: 'USD', + amount: 300, + }, + { + groupId: groupTwo.id, + userId: debtor.id, + firendId: creditor.id, + currency: 'USD', + amount: -200, + }, + { + groupId: groupTwo.id, + userId: creditor.id, + firendId: debtor.id, + currency: 'USD', + amount: 200, + }, + { + groupId: groupOne.id, + userId: debtor.id, + firendId: creditor.id, + currency: 'EUR', + amount: -700, + }, + { + groupId: groupOne.id, + userId: creditor.id, + firendId: debtor.id, + currency: 'EUR', + amount: 700, + }, + ], + }); + + const settlement = await addUserExpense( + debtor.id, + 'Settle up', + 'general', + 5, + SplitType.SETTLEMENT, + 'USD', + [ + { userId: debtor.id, amount: 5 }, + { userId: creditor.id, amount: -5 }, + ], + debtor.id, + new Date('2026-01-01'), + ); + assert.ok(settlement); + + const [initialAllocations, settledGroupBalances, otherCurrencyBalances] = await Promise.all([ + db.settlementAllocation.findMany({ where: { settlementExpenseId: settlement.id } }), + db.groupBalance.findMany({ + where: { groupId: { in: [groupOne.id, groupTwo.id] }, currency: 'USD' }, + }), + db.groupBalance.findMany({ + where: { groupId: { in: [groupOne.id, groupTwo.id] }, currency: 'EUR' }, + }), + ]); + assert.equal(initialAllocations.length, 4); + assert.equal( + settledGroupBalances.every((balance) => 0 === balance.amount), + true, + ); + assert.deepEqual( + otherCurrencyBalances.map((balance) => balance.amount).sort((a, b) => a - b), + [-700, 700], + ); + + await editExpense( + settlement.id, + debtor.id, + 'Settle up', + 'general', + 2, + SplitType.SETTLEMENT, + 'USD', + [ + { userId: debtor.id, amount: 2 }, + { userId: creditor.id, amount: -2 }, + ], + debtor.id, + new Date('2026-01-01'), + ); + + const [overallBalance, restoredGroupBalances, partialAllocations] = await Promise.all([ + db.balance.findUniqueOrThrow({ + where: { + userId_currency_friendId: { + userId: creditor.id, + currency: 'USD', + friendId: debtor.id, + }, + }, + }), + db.groupBalance.findMany({ + where: { groupId: { in: [groupOne.id, groupTwo.id] }, userId: creditor.id }, + orderBy: { groupId: 'asc' }, + }), + db.settlementAllocation.findMany({ where: { settlementExpenseId: settlement.id } }), + ]); + + assert.equal(overallBalance.amount, 300); + const restoredAmounts = new Map( + restoredGroupBalances.map((balance) => [balance.groupId, balance.amount]), + ); + assert.equal(restoredAmounts.get(groupOne.id), 300); + assert.equal(restoredAmounts.get(groupTwo.id), 200); + assert.equal(partialAllocations.length, 0); + + await editExpense( + settlement.id, + debtor.id, + 'Settle up', + 'general', + 5, + SplitType.SETTLEMENT, + 'USD', + [ + { userId: debtor.id, amount: 5 }, + { userId: creditor.id, amount: -5 }, + ], + debtor.id, + new Date('2026-01-01'), + ); + await createGroupExpense( + groupOne.id, + creditor.id, + 'Later expense', + 'general', + 1, + SplitType.EQUAL, + 'USD', + [ + { userId: creditor.id, amount: 1 }, + { userId: debtor.id, amount: -1 }, + ], + creditor.id, + new Date('2026-02-01'), + ); + await editExpense( + settlement.id, + debtor.id, + 'Updated settlement note', + 'general', + 5, + SplitType.SETTLEMENT, + 'USD', + [ + { userId: debtor.id, amount: 5 }, + { userId: creditor.id, amount: -5 }, + ], + debtor.id, + new Date('2026-01-01'), + ); + + const [balancesAfterMetadataEdit, allocationsAfterMetadataEdit] = await Promise.all([ + db.groupBalance.findMany({ + where: { groupId: { in: [groupOne.id, groupTwo.id] }, userId: creditor.id }, + }), + db.settlementAllocation.findMany({ where: { settlementExpenseId: settlement.id } }), + ]); + const metadataEditAmounts = new Map( + balancesAfterMetadataEdit.map((balance) => [balance.groupId, balance.amount]), + ); + assert.equal(metadataEditAmounts.get(groupOne.id), 100); + assert.equal(metadataEditAmounts.get(groupTwo.id), 0); + assert.equal(allocationsAfterMetadataEdit.length, 4); + + await deleteExpense(settlement.id, debtor.id); + + const [deletedOverallBalance, deletedGroupBalances, deletedAllocations] = await Promise.all([ + db.balance.findUniqueOrThrow({ + where: { + userId_currency_friendId: { + userId: creditor.id, + currency: 'USD', + friendId: debtor.id, + }, + }, + }), + db.groupBalance.findMany({ + where: { groupId: { in: [groupOne.id, groupTwo.id] }, userId: creditor.id }, + orderBy: { groupId: 'asc' }, + }), + db.settlementAllocation.findMany({ where: { settlementExpenseId: settlement.id } }), + ]); + + assert.equal(deletedOverallBalance.amount, 600); + const deletedAmounts = new Map( + deletedGroupBalances.map((balance) => [balance.groupId, balance.amount]), + ); + assert.equal(deletedAmounts.get(groupOne.id), 400); + assert.equal(deletedAmounts.get(groupTwo.id), 200); + assert.equal(deletedAllocations.length, 0); + + const legacySettlement = await db.expense.create({ + data: { + paidBy: debtor.id, + addedBy: debtor.id, + name: 'Legacy settlement', + category: 'general', + amount: 100, + splitType: SplitType.SETTLEMENT, + currency: 'USD', + expenseParticipants: { + create: [ + { userId: debtor.id, amount: 100 }, + { userId: creditor.id, amount: -100 }, + ], + }, + }, + }); + await assert.rejects( + () => deleteExpense(legacySettlement.id, debtor.id), + /Legacy settlements cannot be edited or deleted safely/, + ); + + const detachedSettlement = await addUserExpense( + debtor.id, + 'Settle before leaving', + 'general', + 6, + SplitType.SETTLEMENT, + 'USD', + [ + { userId: debtor.id, amount: 6 }, + { userId: creditor.id, amount: -6 }, + ], + debtor.id, + new Date('2026-03-01'), + ); + await db.group.delete({ where: { id: groupOne.id } }); + await db.groupUser.delete({ + where: { groupId_userId: { groupId: groupTwo.id, userId: debtor.id } }, + }); + await assert.rejects( + () => + createGroupExpense( + groupTwo.id, + creditor.id, + 'Expense after leaving', + 'general', + 1, + SplitType.EQUAL, + 'USD', + [ + { userId: creditor.id, amount: 1 }, + { userId: debtor.id, amount: -1 }, + ], + creditor.id, + new Date('2026-03-02'), + ), + /must be current group members/, + ); + await editExpense( + detachedSettlement.id, + debtor.id, + 'Partial after leaving', + 'general', + 3, + SplitType.SETTLEMENT, + 'USD', + [ + { userId: debtor.id, amount: 3 }, + { userId: creditor.id, amount: -3 }, + ], + debtor.id, + new Date('2026-03-01'), + ); + + const [detachedBalance, detachedGroupBalance, detachedAllocations] = await Promise.all([ + db.balance.findUniqueOrThrow({ + where: { + userId_currency_friendId: { + userId: creditor.id, + currency: 'USD', + friendId: debtor.id, + }, + }, + }), + db.groupBalance.findUniqueOrThrow({ + where: { + groupId_currency_firendId_userId: { + groupId: groupTwo.id, + currency: 'USD', + userId: creditor.id, + firendId: debtor.id, + }, + }, + }), + db.settlementAllocation.findMany({ + where: { settlementExpenseId: detachedSettlement.id }, + }), + ]); + assert.equal(detachedBalance.amount, 300); + assert.equal(detachedGroupBalance.amount, 0); + assert.equal(detachedAllocations.length, 0); + + await db.group.deleteMany({ where: { id: { in: [groupOne.id, groupTwo.id] } } }); + await db.user.deleteMany({ where: { id: { in: [debtor.id, creditor.id] } } }); + await db.$disconnect(); + }, +); diff --git a/src/tests/balances.test.ts b/src/tests/balances.test.ts new file mode 100644 index 00000000..017fd904 --- /dev/null +++ b/src/tests/balances.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { SplitType } from '@prisma/client'; + +import { getGroupBalanceSummary, getGroupTotalsWhere } from '~/utils/balances'; + +void describe('group spending safeguards', () => { + void it('excludes settlements from group spending totals', () => { + assert.deepEqual(getGroupTotalsWhere(42), { + groupId: 42, + deletedAt: null, + splitType: { not: SplitType.SETTLEMENT }, + }); + }); +}); + +void describe('group balance summary', () => { + void it('shows the largest absolute nonzero balance and marks multiple currencies', () => { + assert.deepEqual(getGroupBalanceSummary({ USD: 0, EUR: -2500, GBP: 1000 }, 'USD'), { + amount: -2500, + currency: 'EUR', + multiCurrency: true, + }); + }); + + void it('uses the group default currency when every balance is zero', () => { + assert.deepEqual(getGroupBalanceSummary({ EUR: 0 }, 'GBP'), { + amount: 0, + currency: 'GBP', + multiCurrency: false, + }); + }); +}); diff --git a/src/tests/splits.test.ts b/src/tests/splits.test.ts new file mode 100644 index 00000000..fe983a76 --- /dev/null +++ b/src/tests/splits.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { SplitType, type User } from '@prisma/client'; + +import { calculateParticipantSplit, type Participant, useAddExpenseStore } from '~/store/addStore'; +import { toInteger } from '~/utils/numbers'; +import { toBalancedParticipants } from '~/utils/splits'; + +const createUser = (id: number): User => ({ + id, + name: `User ${id}`, + email: `user-${id}@example.com`, + emailVerified: null, + image: null, + currency: 'USD', +}); + +void describe('split cent conservation', () => { + const users = [createUser(1), createUser(2), createUser(3)]; + const cases = [ + { splitType: SplitType.EQUAL, shares: [1, 1, 1] }, + { splitType: SplitType.PERCENTAGE, shares: [33.33, 33.33, 33.34] }, + { splitType: SplitType.SHARE, shares: [1, 1, 1] }, + { splitType: SplitType.EXACT, shares: [3.33, 3.33, 3.34] }, + { splitType: SplitType.ADJUSTMENT, shares: [0, 0, 0] }, + ]; + + cases.forEach(({ splitType, shares }) => { + users.forEach((payer) => { + void it(`balances ${splitType} cents when user ${payer.id} pays`, () => { + const participants: Participant[] = users.map((user, index) => ({ + ...user, + splitShare: shares[index], + })); + const result = calculateParticipantSplit(10, participants, splitType, payer); + const storedAmounts = result.participants.map((participant) => + toInteger(participant.amount ?? 0), + ); + + assert.equal(result.canSplitScreenClosed, true); + assert.equal( + storedAmounts.reduce((total, participantAmount) => total + participantAmount, 0), + 0, + ); + }); + }); + }); + + void it('rejects zero and non-finite SHARE totals without producing non-finite amounts', () => { + const invalidShares = [ + [0, 0, 0], + [1, -1, 0], + [Number.POSITIVE_INFINITY, 1, 1], + ]; + + invalidShares.forEach((shares) => { + const participants: Participant[] = users.map((user, index) => ({ + ...user, + splitShare: shares[index], + })); + const result = calculateParticipantSplit(10, participants, SplitType.SHARE, users[0]); + + assert.equal(result.canSplitScreenClosed, false); + assert.equal( + result.participants.every((participant) => Number.isFinite(participant.amount)), + true, + ); + }); + }); + + void it('normalizes bounded rounding drift from old clients', () => { + assert.deepEqual( + toBalancedParticipants(10, 1, [ + { userId: 1, amount: 6.67 }, + { userId: 2, amount: -3.33 }, + { userId: 3, amount: -3.33 }, + ]), + [ + { userId: 1, amount: 6.66 }, + { userId: 2, amount: -3.33 }, + { userId: 3, amount: -3.33 }, + ], + ); + }); + + void it('accepts settlement participants', () => { + assert.deepEqual( + toBalancedParticipants(10, 1, [ + { userId: 1, amount: 10 }, + { userId: 2, amount: -10 }, + ]), + [ + { userId: 1, amount: 10 }, + { userId: 2, amount: -10 }, + ], + ); + }); + + void it('rejects material imbalances and invalid debt directions', () => { + assert.throws( + () => + toBalancedParticipants(10, 1, [ + { userId: 1, amount: 5 }, + { userId: 2, amount: -3 }, + ]), + /must balance to zero/, + ); + assert.throws( + () => + toBalancedParticipants(10, 1, [ + { userId: 1, amount: 0 }, + { userId: 2, amount: 5 }, + { userId: 3, amount: -5 }, + ]), + /valid expense/, + ); + assert.throws( + () => + toBalancedParticipants(10, 1, [ + { userId: 2, amount: 0 }, + { userId: 3, amount: 0 }, + ]), + /include the payer exactly once/, + ); + }); +}); + +void describe('split type conversion', () => { + void it('reconstructs percentage shares from the current participant amounts', () => { + const payer = createUser(1); + const friend = createUser(2); + + useAddExpenseStore.setState({ + amount: 10, + paidBy: payer, + splitType: SplitType.EQUAL, + participants: [ + { ...payer, amount: 5, splitShare: 1 }, + { ...friend, amount: -5, splitShare: 1 }, + ], + }); + + useAddExpenseStore.getState().actions.setSplitType(SplitType.PERCENTAGE); + + assert.deepEqual( + useAddExpenseStore.getState().participants.map((participant) => participant.splitShare), + [50, 50], + ); + assert.equal(useAddExpenseStore.getState().canSplitScreenClosed, true); + }); +}); diff --git a/src/utils/balances.ts b/src/utils/balances.ts new file mode 100644 index 00000000..56b5e189 --- /dev/null +++ b/src/utils/balances.ts @@ -0,0 +1,24 @@ +import { SplitType } from '@prisma/client'; + +export const getGroupTotalsWhere = (groupId: number) => ({ + groupId, + deletedAt: null, + splitType: { not: SplitType.SETTLEMENT }, +}); + +export const getGroupBalanceSummary = ( + balances: Record, + defaultCurrency: string, +) => { + const nonzeroBalances = Object.entries(balances).filter(([, amount]) => 0 !== amount); + const [currency, amount] = nonzeroBalances.reduce<[string, number]>( + (largest, balance) => (Math.abs(balance[1]) > Math.abs(largest[1]) ? balance : largest), + [defaultCurrency, 0], + ); + + return { + amount, + currency, + multiCurrency: 1 < nonzeroBalances.length, + }; +}; diff --git a/src/utils/splits.ts b/src/utils/splits.ts new file mode 100644 index 00000000..28bfef7f --- /dev/null +++ b/src/utils/splits.ts @@ -0,0 +1,44 @@ +import { toFixedNumber, toInteger } from './numbers'; + +export const toBalancedParticipants = ( + amount: number, + paidBy: number, + participants: { userId: number; amount: number }[], +) => { + const storedParticipants = participants.map((participant) => ({ + userId: participant.userId, + amount: toInteger(participant.amount), + })); + const payer = storedParticipants.find((participant) => participant.userId === paidBy); + const uniqueParticipantIds = new Set(storedParticipants.map((participant) => participant.userId)); + + if (!payer || uniqueParticipantIds.size !== storedParticipants.length) { + throw new Error('Expense participants must include the payer exactly once'); + } + + const imbalance = storedParticipants.reduce((sum, participant) => sum + participant.amount, 0); + if (storedParticipants.length < Math.abs(imbalance)) { + throw new Error('Participant amounts must balance to zero'); + } + + // Old clients rounded each participant independently. Correct only that bounded rounding drift. + payer.amount -= imbalance; + + const storedAmount = toInteger(amount); + const hasInvalidParticipant = storedParticipants.some((participant) => { + if (participant.userId === paidBy) { + return participant.amount < 0 || storedAmount < participant.amount; + } + + return 0 < participant.amount; + }); + + if (hasInvalidParticipant) { + throw new Error('Participant amounts do not represent a valid expense'); + } + + return storedParticipants.map((participant) => ({ + userId: participant.userId, + amount: toFixedNumber(participant.amount), + })); +};