Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a9ed276
Add typed query to get groups with balances
FriesischScott Jun 5, 2025
beb89da
Return groups without expenses
FriesischScott Jun 7, 2025
df6a7ad
Order groups descending by newest
FriesischScott Jun 8, 2025
ab4c7ee
Compute blances for group overview in db
FriesischScott Jun 8, 2025
56af112
Compute balance list in db
FriesischScott Jun 8, 2025
de25193
Merge branch 'main' into sql-balances
FriesischScott Jun 9, 2025
24d1b99
Include group id in all balances
FriesischScott Jun 9, 2025
70e58c6
Fix linting
FriesischScott Jun 9, 2025
a40ca8c
Move prisma client to src/prisma/client
FriesischScott Jun 9, 2025
cee753c
Fix prettier warning
FriesischScott Jun 9, 2025
23fece2
Use path alias to move prisma client
FriesischScott Jun 9, 2025
b3002be
gitignore prisma client files
FriesischScott Jun 9, 2025
20fb843
Fix sql imports
FriesischScott Jun 9, 2025
44a3bdd
Set prisma client output
FriesischScott Jun 9, 2025
a36344d
Run lint --fix
FriesischScott Jun 9, 2025
cd32056
Fix type issues
FriesischScott Jun 9, 2025
c619e26
Fix prettier warnings
FriesischScott Jun 9, 2025
646c2e0
Add postgresql service container
FriesischScott Jun 9, 2025
7de67eb
Run migrations before generating sql types
FriesischScott Jun 9, 2025
bb996e9
Merge branch 'main' into sql-balances
FriesischScott Jun 9, 2025
fa22c43
Remove sql types
FriesischScott Jun 9, 2025
1ff2229
Fix workflow file
FriesischScott Jun 9, 2025
6ff9a98
Fix bigint conversions
FriesischScott Jun 9, 2025
0e5b29c
Fix lint
FriesischScott Jun 9, 2025
bec3f4d
Seed db for stress testing
FriesischScott Jun 10, 2025
af4c06d
Fix prettier warnings
FriesischScott Jun 10, 2025
2684a71
Exclude seed.ts
FriesischScott Jun 10, 2025
4b02518
Don't set prisma output folder
FriesischScott Jun 10, 2025
c26dc4a
Exclude deleted expenses from group balance
FriesischScott Jun 10, 2025
06eea78
Update seed
FriesischScott Jun 18, 2025
bca6c9d
Add userid index to ExpenseParticipant
FriesischScott Jun 18, 2025
4dc3d38
Merge branch 'main' into sql-balances
FriesischScott Jun 18, 2025
1f330a5
Fix prettier warnings
FriesischScott Jun 18, 2025
3270d55
Merge remote-tracking branch 'upstream/main' into sql-balances
FriesischScott Oct 9, 2025
510df2e
Format SQL queries
FriesischScott Oct 9, 2025
391f161
Switch service container to ossapps/postgres
FriesischScott Oct 11, 2025
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
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/prisma/client/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
/src/prisma/client

# next.js
/.next/
Expand Down
3 changes: 2 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

generator client {
provider = "prisma-client-js"
previewFeatures = ["relationJoins"]
output = "../src/prisma/client"
previewFeatures = ["relationJoins", "typedSQL"]
}

datasource db {
Expand Down
2 changes: 1 addition & 1 deletion prisma/seed.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

The original seed script is useful for local dev work, while this one takes a long time to execute. Please move it to a separate file

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My plan was to only use this as and intermediate script and revert to the original before we merge. If you think it's worth keeping around I'll move it to a separate script.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient } from '~/prisma/client';

const prisma = new PrismaClient();

Expand Down
6 changes: 6 additions & 0 deletions prisma/sql/getAllBalancesForGroup.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- @param {Int} $1:id of the group
select "groupId", "userId" as "borrowedBy", "paidBy", "currency", Coalesce(-1 * sum("ExpenseParticipant".amount),0) as amount FROM "Expense"
JOIN "ExpenseParticipant" ON "ExpenseParticipant"."expenseId" = "Expense".id
WhERE "groupId" = $1 AND "userId" != "paidBy"
GROUP BY "userId", "paidBy", "currency", "groupId"
ORDER By "currency"
9 changes: 9 additions & 0 deletions prisma/sql/getGroupsWithBalances.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- @param {Int} $1:id of the user
SELECT "Group"."id", "Group".name, Coalesce(sum("ExpenseParticipant".amount),0) as balance, Coalesce("Expense".currency, "Group"."defaultCurrency") as currency
FROM "GroupUser"
JOIN "Group" ON "GroupUser"."groupId" = "Group".id
LEFT JOIN "Expense" ON "Expense"."groupId" = "Group".id
LEFT JOIN "ExpenseParticipant" ON "Expense".id = "ExpenseParticipant"."expenseId"
WHERE "GroupUser"."userId" = $1 AND "deletedAt" IS NULL AND ("ExpenseParticipant"."userId" = $1 OR "Expense".id is null)
GROUP BY "Group".id, "Group".name, "Expense".currency
ORDER BY "Group"."createdAt" DESC, balance DESC
2 changes: 1 addition & 1 deletion src/components/AddExpense/SelectUserOrGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { CheckIcon } from '@heroicons/react/24/outline';
import { UserPlusIcon } from '@heroicons/react/24/solid';
import { type Group, type GroupUser, type User } from '@prisma/client';
import { motion } from 'framer-motion';
import { SendIcon } from 'lucide-react';
import Image from 'next/image';
import React from 'react';
import { z } from 'zod';

import { type Group, type GroupUser, type User } from '~/prisma/client';
import { useAddExpenseStore } from '~/store/addStore';
import { api } from '~/utils/api';

Expand Down
2 changes: 1 addition & 1 deletion src/components/AddExpense/SplitTypeSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { SplitType } from '@prisma/client';
import clsx from 'clsx';
import {
BarChart2,
Expand All @@ -12,6 +11,7 @@ import {
} from 'lucide-react';
import { type ChangeEvent, useCallback, useMemo } from 'react';

import { SplitType } from '~/prisma/client';
import { type AddExpenseState, type Participant, useAddExpenseStore } from '~/store/addStore';
import { removeTrailingZeros, toSafeBigInt, toUIString } from '~/utils/numbers';

Expand Down
20 changes: 11 additions & 9 deletions src/components/Expense/BalanceList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { GroupBalance, User } from '@prisma/client';
import clsx from 'clsx';
import { Info } from 'lucide-react';
import { useMemo } from 'react';

import { UserAvatar } from '~/components/ui/avatar';
import type { User } from '~/prisma/client';
import { type getAllBalancesForGroup } from '~/prisma/client/sql';
import { api } from '~/utils/api';
import { BigMath, toUIString } from '~/utils/numbers';
import { displayName } from '~/utils/strings';
Expand All @@ -18,7 +19,7 @@
}

export const BalanceList: React.FC<{
groupBalances: GroupBalance[];
groupBalances: getAllBalancesForGroup.Result[];
users: User[];
}> = ({ groupBalances, users }) => {
const userQuery = api.user.me.useQuery();
Expand All @@ -32,16 +33,17 @@
{} as Record<number, UserWithBalance>,
);
groupBalances
.filter(({ amount }) => BigMath.abs(amount) > 0)
.filter(({ amount }) => amount != null && BigMath.abs(amount) > 0)
.forEach((balance) => {
if (!res[balance.userId]!.balances[balance.firendId]) {
res[balance.userId]!.balances[balance.firendId] = {};
if (!res[balance.paidBy]!.balances[balance.borrowedBy]) {
res[balance.paidBy]!.balances[balance.borrowedBy] = {};
}
const friendBalance = res[balance.userId]!.balances[balance.firendId]!;
friendBalance[balance.currency] = (friendBalance[balance.currency] ?? 0n) + balance.amount;
const friendBalance = res[balance.paidBy]!.balances[balance.borrowedBy]!;
friendBalance[balance.currency] =
(friendBalance[balance.currency] ?? 0n) + (balance.amount ?? 0n);

res[balance.userId]!.total[balance.currency] =
(res[balance.userId]!.total[balance.currency] ?? 0n) + balance.amount;
res[balance.paidBy]!.total[balance.currency] =
(res[balance.paidBy]!.total[balance.currency] ?? 0n) + (balance.amount ?? 0n);
});

return res;
Expand Down Expand Up @@ -109,7 +111,7 @@
user={user}
amount={amount}
currency={currency}
groupId={groupBalances[0]!.groupId}

Check failure on line 114 in src/components/Expense/BalanceList.tsx

View workflow job for this annotation

GitHub Actions / check

Type 'number | null' is not assignable to type 'number'.
>
<div className="mb-4 ml-5 flex cursor-pointer items-center gap-3 text-sm">
<UserAvatar user={friend} size={20} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/Expense/ExpenseList.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { SplitType } from '@prisma/client';
import { type inferRouterOutputs } from '@trpc/server';
import { format } from 'date-fns';
import Image from 'next/image';
import Link from 'next/link';
import React from 'react';

import { CategoryIcon } from '~/components/ui/categoryIcons';
import { SplitType } from '~/prisma/client';
import { type GroupRouter } from '~/server/api/routers/group';
import { type UserRouter } from '~/server/api/routers/user';
import { toUIString } from '~/utils/numbers';
Expand Down
2 changes: 1 addition & 1 deletion src/components/Expense/ExpensePage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { type Expense, type ExpenseParticipant, type User } from '@prisma/client';
import { format, isSameDay } from 'date-fns';
import { Banknote } from 'lucide-react';
import Image from 'next/image';
import { type User as NextUser } from 'next-auth';
import React from 'react';

import { type Expense, type ExpenseParticipant, type User } from '~/prisma/client';
import { toUIString } from '~/utils/numbers';

import { UserAvatar } from '../ui/avatar';
Expand Down
2 changes: 1 addition & 1 deletion src/components/Friend/Export.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { type Expense, type ExpenseParticipant, SplitType } from '@prisma/client';
import { format } from 'date-fns';
import { Download } from 'lucide-react';
import React from 'react';

import { Button } from '~/components/ui/button';
import { type Expense, type ExpenseParticipant, SplitType } from '~/prisma/client';
import { toUIString } from '~/utils/numbers';

interface ExportCSVProps {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Friend/FirendBalance.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type Balance, type User } from '@prisma/client';
import clsx from 'clsx';

import { type Balance, type User } from '~/prisma/client';
import { toUIString } from '~/utils/numbers';

import { UserAvatar } from '../ui/avatar';
Expand Down
2 changes: 1 addition & 1 deletion src/components/Friend/GroupSettleup.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { SplitType, type User } from '@prisma/client';
import { ArrowRightIcon } from 'lucide-react';
import React, { type ReactNode, useCallback, useState } from 'react';
import { toast } from 'sonner';

import { SplitType, type User } from '~/prisma/client';
import { api } from '~/utils/api';
import { toSafeBigInt, toUIString } from '~/utils/numbers';
import { displayName } from '~/utils/strings';
Expand Down
2 changes: 1 addition & 1 deletion src/components/Friend/Settleup.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { type Balance, SplitType, type User } from '@prisma/client';
import { ArrowRightIcon } from 'lucide-react';
import { type User as NextUser } from 'next-auth';
import React, { useState } from 'react';
import { toast } from 'sonner';

import { type Balance, SplitType, type User } from '~/prisma/client';
import { api } from '~/utils/api';
import { BigMath, toSafeBigInt, toUIString } from '~/utils/numbers';

Expand Down
2 changes: 1 addition & 1 deletion src/components/group/AddMembers.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { UserPlusIcon } from '@heroicons/react/24/solid';
import { type Group, type GroupUser } from '@prisma/client';
import clsx from 'clsx';
import { CheckIcon, SendIcon } from 'lucide-react';
import React, { useState } from 'react';
import { z } from 'zod';

import { Button } from '~/components/ui/button';
import { AppDrawer } from '~/components/ui/drawer';
import { type Group, type GroupUser } from '~/prisma/client';
import { api } from '~/utils/api';

import { UserAvatar } from '../ui/avatar';
Expand Down
2 changes: 1 addition & 1 deletion src/components/group/GroupMyBalance.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type GroupBalance, type User } from '@prisma/client';
import React from 'react';

import { type GroupBalance, type User } from '~/prisma/client';
import { BigMath, toUIString } from '~/utils/numbers';

type GroupMyBalanceProps = {
Expand Down
2 changes: 1 addition & 1 deletion src/components/group/NoMembers.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { type Group, type GroupUser } from '@prisma/client';
import { Share, UserPlus } from 'lucide-react';
import React, { useState } from 'react';

import { Button } from '~/components/ui/button';
import { type Group, type GroupUser } from '~/prisma/client';

import AddMembers from './AddMembers';

Expand Down
62 changes: 31 additions & 31 deletions src/lib/simplify.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type GroupBalance } from '@prisma/client';
import { addHours } from 'date-fns';
import { type getAllBalancesForGroup } from '~/prisma/client/sql';

import { simplifyDebts } from './simplify';

Expand All @@ -9,46 +8,45 @@ type MinimalEdge = {
amount: bigint;
};

const sortByIds = (a: GroupBalance, b: GroupBalance) => {
if (a.userId === b.userId) {
return a.firendId - b.firendId;
const sortByIds = (a: getAllBalancesForGroup.Result, b: getAllBalancesForGroup.Result) => {
if (a.paidBy === b.paidBy) {
return a.borrowedBy - b.borrowedBy;
}
return a.userId - b.userId;
return a.paidBy - b.paidBy;
Comment on lines +11 to +15
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would prefer to keep this PR as minimal as possible. As such, please keep the property names identical and create a type GroupBalance = getAllBalancesForGroup.Result alias

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to confirm, you want me to keep the existing firendId including the typo?

Copy link
Collaborator

Choose a reason for hiding this comment

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

While I agree the naming even without the typo is quite unfortunate, I would prefer to make such a critical PR as small and easy to review as possible. We can think about another PR afterwards.

};

let dateCounter = 0;

const edgeToGroupBalance = (edge: MinimalEdge): [GroupBalance, GroupBalance] => {
const edgeToGroupBalance = (
edge: MinimalEdge,
): [getAllBalancesForGroup.Result, getAllBalancesForGroup.Result] => {
const base = {
groupId: 0,
currency: 'USD',
updatedAt: addHours(new Date(), dateCounter++),
};
return [
{
userId: edge.userOne,
firendId: edge.userTwo,
paidBy: edge.userOne,
borrowedBy: edge.userTwo,
amount: edge.amount,
...base,
},
{
userId: edge.userTwo,
firendId: edge.userOne,
paidBy: edge.userTwo,
borrowedBy: edge.userOne,
amount: edge.amount === 0n ? 0n : -edge.amount,
...base,
},
];
};

const padWithZeroBalances: (balances: GroupBalance[], userCount: number) => GroupBalance[] = (
balances,
userCount,
) => {
const padWithZeroBalances: (
balances: getAllBalancesForGroup.Result[],
userCount: number,
) => getAllBalancesForGroup.Result[] = (balances, userCount) => {
const result = [...balances];
for (let userId = 0; userId < userCount; userId++) {
for (let friendId = userId + 1; friendId < userCount; friendId++) {
const found = balances.find(
(balance) => balance.userId === userId && balance.firendId === friendId,
(balance) => balance.paidBy === userId && balance.borrowedBy === friendId,
);

if (!found) {
Expand All @@ -59,14 +57,17 @@ const padWithZeroBalances: (balances: GroupBalance[], userCount: number) => Grou
return result;
};

const getFullBalanceGraph = (edges: MinimalEdge[], userCount: number): GroupBalance[] => {
const getFullBalanceGraph = (
edges: MinimalEdge[],
userCount: number,
): getAllBalancesForGroup.Result[] => {
const arr = padWithZeroBalances(edges.flatMap(edgeToGroupBalance), userCount);
arr.sort(sortByIds);
return arr;
};

// taken from https://www.geeksforgeeks.org/minimize-cash-flow-among-given-set-friends-borrowed-money/
const smallGraph: GroupBalance[] = getFullBalanceGraph(
const smallGraph: getAllBalancesForGroup.Result[] = getFullBalanceGraph(
[
{ userOne: 0, userTwo: 1, amount: 1000n },
{ userOne: 1, userTwo: 2, amount: 5000n },
Expand All @@ -75,19 +76,16 @@ const smallGraph: GroupBalance[] = getFullBalanceGraph(
3,
);

const smallGraphResult: GroupBalance[] = getFullBalanceGraph(
const smallGraphResult: getAllBalancesForGroup.Result[] = getFullBalanceGraph(
[
{ userOne: 1, userTwo: 2, amount: 4000n },
{ userOne: 2, userTwo: 0, amount: -3000n },
],
3,
).map((resultBalance, idx) => ({
...resultBalance,
updatedAt: smallGraph[idx]!.updatedAt,
}));
);

// taken from https://medium.com/@mithunmk93/algorithm-behind-splitwises-debt-simplification-feature-8ac485e97688
const largeGraph: GroupBalance[] = getFullBalanceGraph(
const largeGraph: getAllBalancesForGroup.Result[] = getFullBalanceGraph(
[
{ userOne: 1, userTwo: 2, amount: 4000n },
{ userOne: 1, userTwo: 5, amount: -1000n },
Expand All @@ -105,7 +103,7 @@ const largeGraph: GroupBalance[] = getFullBalanceGraph(
7,
);

const denseGraph: GroupBalance[] = getFullBalanceGraph(
const denseGraph: getAllBalancesForGroup.Result[] = getFullBalanceGraph(
[
{ userOne: 0, userTwo: 1, amount: 895795n },
{ userOne: 0, userTwo: 2, amount: 328043n },
Expand Down Expand Up @@ -142,23 +140,25 @@ describe('simplifyDebts', () => {
{ graph: largeGraph, expected: 3 },
{ graph: denseGraph, expected: 5 },
])('gets the optimal operation count', ({ graph, expected }) => {
expect(simplifyDebts(graph).filter((balance) => balance.amount > 0).length).toBe(expected);
expect(simplifyDebts(graph).filter((balance) => (balance.amount ?? 0n) > 0).length).toBe(
expected,
);
});

it.each([{ graph: smallGraph }, { graph: largeGraph }, { graph: denseGraph }])(
'preserves the total balance per user',
({ graph }) => {
const startingBalances = graph.reduce(
(acc, balance) => {
acc[balance.userId] = (acc[balance.userId] ?? 0n) + balance.amount;
acc[balance.paidBy] = (acc[balance.paidBy] ?? 0n) + (balance.amount ?? 0n);
return acc;
},
{} as Record<number, bigint>,
);

const userBalances = simplifyDebts(graph).reduce(
(acc, balance) => {
acc[balance.userId] = (acc[balance.userId] ?? 0n) + balance.amount;
acc[balance.paidBy] = (acc[balance.paidBy] ?? 0n) + (balance.amount ?? 0n);
return acc;
},
{} as Record<number, bigint>,
Expand Down
Loading
Loading