Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -105,4 +106,4 @@
"seed": "tsx prisma/seed.ts"
},
"packageManager": "pnpm@8.9.2"
}
}
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 17 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -151,18 +151,35 @@ 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)
deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade)
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
Expand Down
71 changes: 26 additions & 45 deletions src/pages/add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,15 @@ 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';

// 🧾

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(() => {
Expand Down Expand Up @@ -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,
);
Comment on lines +79 to 87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle zero-value SHARE expenses before reconstructing shares.

Line 79 can hydrate a zero-amount SHARE expense. calculateSplitShareBasedOnAmount divides by amount before its zero guard, so splitShare becomes NaN and the edit flow cannot produce valid participant values.

Proposed fix in src/store/addStore.ts
     case SplitType.SHARE:
+      if (amount === 0) {
+        return participants.map((participant) => ({
+          ...participant,
+          splitShare: 0,
+        }));
+      }
+
       const shares = participants.map((p) =>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/add.tsx` around lines 79 - 87, Handle zero-value SHARE expenses
before the edit flow calls calculateSplitShareBasedOnAmount. Update the
surrounding addStore/share-reconstruction logic so zero amounts use the existing
zero-share behavior before any division occurs, preventing splitShare from
becoming NaN and preserving valid participant values.

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]);
Expand Down
15 changes: 10 additions & 5 deletions src/pages/groups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -47,9 +48,10 @@ const BalancePage: NextPageWithUser = () => {
</motion.div>
) : (
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 (
<GroupBalance
key={g.id}
Expand All @@ -58,6 +60,7 @@ const BalancePage: NextPageWithUser = () => {
amount={amount}
isPositive={amount >= 0 ? true : false}
currency={currency}
multiCurrency={multiCurrency}
/>
);
})
Expand All @@ -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 (
<Link href={`/groups/${groupId}`}>
<div className="flex items-center justify-between">
Expand All @@ -98,6 +102,7 @@ const GroupBalance: React.FC<{
</div>
<div className={`${isPositive ? 'text-emerald-500' : 'text-orange-600'} text-right`}>
{currency} {toUIString(amount)}
{multiCurrency ? '*' : ''}
</div>
</>
)}
Expand All @@ -109,4 +114,4 @@ const GroupBalance: React.FC<{

BalancePage.auth = true;

export default BalancePage;
export default BalancePage;
36 changes: 18 additions & 18 deletions src/server/api/routers/group.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -262,10 +264,7 @@ export const groupRouter = createTRPCRouter({
_sum: {
amount: true,
},
where: {
groupId: input.groupId,
deletedAt: null,
},
where: getGroupTotalsWhere(input.groupId),
});

return totals;
Expand All @@ -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,
Expand All @@ -305,22 +303,20 @@ export const groupRouter = createTRPCRouter({
});
}

const groupUser = await ctx.db.groupUser.delete({
return tx.groupUser.delete({
where: {
groupId_userId: {
groupId: input.groupId,
userId: ctx.session.user.id,
},
},
});

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,
},
Expand All @@ -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({
Expand All @@ -342,12 +341,13 @@ export const groupRouter = createTRPCRouter({
});
}

await ctx.db.group.delete({
await tx.group.delete({
where: {
id: input.groupId,
},
});

return group;
}),
),
});
56 changes: 28 additions & 28 deletions src/server/api/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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;
Expand Down
Loading