Skip to content

Commit cbf8c21

Browse files
authored
Migrate to balance views (#490)
* Remove balances from schema * Remove usage of balance model from straightforward calls * Update migration to preserve balance data * Switch all code to use balance view * Remove balance view * Update seed command * Fix issues identified by tsc * Fix ordering in cumulated balances * Migrate splitwise balances and import * Migrate friend deletion * Squash prisma migrations * Review fixes
1 parent 578c34f commit cbf8c21

21 files changed

Lines changed: 659 additions & 1442 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
-- DropForeignKey
2+
ALTER TABLE "public"."Balance" DROP CONSTRAINT "Balance_friendId_fkey";
3+
4+
-- DropForeignKey
5+
ALTER TABLE "public"."Balance" DROP CONSTRAINT "Balance_userId_fkey";
6+
7+
-- DropForeignKey
8+
ALTER TABLE "public"."GroupBalance" DROP CONSTRAINT "GroupBalance_firendId_fkey";
9+
10+
-- DropForeignKey
11+
ALTER TABLE "public"."GroupBalance" DROP CONSTRAINT "GroupBalance_groupId_fkey";
12+
13+
-- DropForeignKey
14+
ALTER TABLE "public"."GroupBalance" DROP CONSTRAINT "GroupBalance_userId_fkey";
15+
16+
-- Adjust Splitwise imported balances to account for BalanceView only counting expenses
17+
WITH "Differences" AS (
18+
SELECT
19+
B."userId",
20+
B."friendId",
21+
B."currency",
22+
-- Calculate the difference
23+
(B."amount" - COALESCE(BV."amount", 0)) AS "diff_amount",
24+
B."createdAt",
25+
-- Pre-generate the Expense ID
26+
gen_random_uuid() AS "new_expense_id"
27+
FROM
28+
"Balance" B
29+
LEFT JOIN (
30+
SELECT "userId", "friendId", "currency", SUM("amount") as "amount"
31+
FROM "BalanceView"
32+
GROUP BY "userId", "friendId", "currency"
33+
) BV ON B."userId" = BV."userId"
34+
AND B."friendId" = BV."friendId"
35+
AND B."currency" = BV."currency"
36+
WHERE
37+
B."importedFromSplitwise" = true
38+
AND B."userId" < B."friendId"
39+
AND (B."amount" - COALESCE(BV."amount", 0)) != 0
40+
),
41+
"InsertExpenses" AS (
42+
INSERT INTO "Expense" (
43+
"id",
44+
"paidBy",
45+
"addedBy",
46+
"name",
47+
"category",
48+
"amount",
49+
"currency",
50+
"splitType",
51+
"expenseDate",
52+
"createdAt",
53+
"updatedAt",
54+
"groupId"
55+
)
56+
SELECT
57+
"new_expense_id",
58+
-- Determine Payer: If diff < 0, Friend is creditor. If diff > 0, User is creditor.
59+
CASE WHEN "diff_amount" < 0 THEN "friendId" ELSE "userId" END,
60+
CASE WHEN "diff_amount" < 0 THEN "friendId" ELSE "userId" END,
61+
'Splitwise Balance Import',
62+
'general',
63+
ABS("diff_amount"), -- Expense amount is always positive
64+
"currency",
65+
'EXACT',
66+
"createdAt",
67+
"createdAt",
68+
"createdAt",
69+
NULL
70+
FROM "Differences"
71+
-- RETURNING needed info for the next step
72+
RETURNING "id", "amount", "paidBy"
73+
),
74+
"InsertParticipants" AS (
75+
INSERT INTO "ExpenseParticipant" (
76+
"expenseId",
77+
"userId",
78+
"amount"
79+
)
80+
-- Row 1: The Payer (Creditor) -> Positive Amount
81+
SELECT
82+
ie."id",
83+
ie."paidBy",
84+
ie."amount" -- Positive flow (they paid/are owed)
85+
FROM "InsertExpenses" ie
86+
87+
UNION ALL
88+
89+
-- Row 2: The Debtor -> Negative Amount
90+
SELECT
91+
ie."id",
92+
-- The Debtor is whoever is NOT the payer.
93+
CASE
94+
WHEN ie."paidBy" = d."friendId" THEN d."userId"
95+
ELSE d."friendId"
96+
END,
97+
-ie."amount" -- Negative flow (they owe)
98+
FROM "InsertExpenses" ie
99+
JOIN "Differences" d ON ie."id" = d."new_expense_id"
100+
)
101+
SELECT count(*) as "AdjustmentsCreated" FROM "InsertExpenses";
102+
103+
-- AlterTable
104+
ALTER TABLE "public"."User" ADD COLUMN "hiddenFriendIds" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
105+
106+
-- Function to remove a user ID from the hiddenFriendIds array
107+
CREATE OR REPLACE FUNCTION public.auto_unhide_friend()
108+
RETURNS TRIGGER AS $$
109+
BEGIN
110+
-- If a new participant entry is added/updated
111+
-- We must ensure the 'userId' un-hides the 'payer' (and vice versa)
112+
-- But since we don't have the Payer ID easily in the Participant row alone,
113+
-- we rely on the fact that an Expense creation/update usually touches both parties.
114+
115+
-- SCENARIO: Remove the NEW.userId from the Payer's hidden list, and vice versa.
116+
-- However, doing this purely from ExpenseParticipant is hard because we need the 'paidBy' from Expense.
117+
118+
-- SIMPLIFIED APPROACH:
119+
-- When money flows, we simply try to remove the IDs from the array.
120+
-- PostgreSQL's array_remove function handles this gracefully (does nothing if ID not found).
121+
122+
-- Note: This trigger logic assumes we can join to the Expense table.
123+
-- It fires AFTER INSERT on ExpenseParticipant.
124+
125+
UPDATE "User"
126+
SET "hiddenFriendIds" = array_remove("hiddenFriendIds", NEW."paidBy")
127+
WHERE id = NEW."userId";
128+
129+
UPDATE "User"
130+
SET "hiddenFriendIds" = array_remove("hiddenFriendIds", NEW."userId")
131+
WHERE id = NEW."paidBy";
132+
133+
RETURN NEW;
134+
END;
135+
$$ LANGUAGE plpgsql;
136+
137+
-- Trigger definition
138+
DROP TRIGGER IF EXISTS trigger_auto_unhide_friend ON "ExpenseParticipant";
139+
CREATE TRIGGER trigger_auto_unhide_friend
140+
AFTER INSERT ON "ExpenseParticipant"
141+
FOR EACH ROW
142+
EXECUTE FUNCTION public.auto_unhide_friend();

prisma/schema.prisma

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ model User {
4949
bankingId String?
5050
obapiProviderId String?
5151
accounts Account[]
52-
friendBalances Balance[] @relation("FriendBalance")
53-
userBalances Balance[] @relation("UserBalance")
52+
friendBalances BalanceView[] @relation("FriendBalanceView")
53+
userBalances BalanceView[] @relation("UserBalanceView")
5454
cachedBankData CachedBankData[] @relation("UserCachedBankData")
5555
addedExpenses Expense[] @relation("AddedByUser")
5656
deletedExpenses Expense[] @relation("DeletedByUser")
@@ -59,12 +59,9 @@ model User {
5959
expenseNotes ExpenseNote[]
6060
expenseParticipants ExpenseParticipant[]
6161
groups Group[]
62-
groupFriendBalances GroupBalance[] @relation("GroupFriendBalance")
63-
groupUserBalances GroupBalance[] @relation("GroupUserBalance")
6462
associatedGroups GroupUser[]
6563
sessions Session[]
66-
userBalanceViews BalanceView[] @relation("UserBalanceView")
67-
friendBalanceViews BalanceView[] @relation("FriendBalanceView")
64+
hiddenFriendIds Int[] @default([])
6865
6966
@@schema("public")
7067
}
@@ -91,6 +88,7 @@ model VerificationToken {
9188
@@schema("public")
9289
}
9390

91+
// Deprecated in favor of BalanceView. Kept to preserve existing data for now.
9492
model Balance {
9593
userId Int
9694
currency String
@@ -99,29 +97,28 @@ model Balance {
9997
createdAt DateTime @default(now())
10098
updatedAt DateTime @updatedAt
10199
importedFromSplitwise Boolean @default(false)
102-
friend User @relation("FriendBalance", fields: [friendId], references: [id], onDelete: Cascade)
103-
user User @relation("UserBalance", fields: [userId], references: [id], onDelete: Cascade)
100+
// friend User @relation("FriendBalance", fields: [friendId], references: [id], onDelete: Cascade)
101+
// user User @relation("UserBalance", fields: [userId], references: [id], onDelete: Cascade)
104102
105103
@@id([userId, currency, friendId])
106104
@@schema("public")
107105
}
108106

109107
model Group {
110-
id Int @id @default(autoincrement())
111-
publicId String @unique
112-
name String
113-
userId Int
114-
defaultCurrency String @default("USD")
115-
createdAt DateTime @default(now())
116-
updatedAt DateTime @updatedAt
117-
splitwiseGroupId String? @unique
118-
simplifyDebts Boolean @default(false)
119-
archivedAt DateTime?
120-
expenses Expense[]
121-
createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade)
122-
groupBalances GroupBalance[]
123-
groupUsers GroupUser[]
124-
groupBalanceViews BalanceView[]
108+
id Int @id @default(autoincrement())
109+
publicId String @unique
110+
name String
111+
userId Int
112+
defaultCurrency String @default("USD")
113+
createdAt DateTime @default(now())
114+
updatedAt DateTime @updatedAt
115+
splitwiseGroupId String? @unique
116+
simplifyDebts Boolean @default(false)
117+
archivedAt DateTime?
118+
expenses Expense[]
119+
createdBy User @relation(fields: [userId], references: [id], onDelete: Cascade)
120+
groupUsers GroupUser[]
121+
groupBalances BalanceView[]
125122
126123
@@schema("public")
127124
}
@@ -136,16 +133,17 @@ model GroupUser {
136133
@@schema("public")
137134
}
138135

136+
// Deprecated in favor of BalanceView. Kept to preserve existing data for now.
139137
model GroupBalance {
140138
groupId Int
141139
currency String
142140
userId Int
143141
firendId Int
144142
amount BigInt
145143
updatedAt DateTime @updatedAt
146-
friend User @relation("GroupFriendBalance", fields: [firendId], references: [id], onDelete: Cascade)
147-
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
148-
user User @relation("GroupUserBalance", fields: [userId], references: [id], onDelete: Cascade)
144+
// friend User @relation("GroupFriendBalance", fields: [firendId], references: [id], onDelete: Cascade)
145+
// group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
146+
// user User @relation("GroupUserBalance", fields: [userId], references: [id], onDelete: Cascade)
149147
150148
@@id([groupId, currency, firendId, userId])
151149
@@schema("public")

0 commit comments

Comments
 (0)