Repository navigation
Fix/604 date aware balances #779
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
krokosik
wants to merge
4
commits into
main
Choose a base branch
from
fix/604-date-aware-balances
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
eec10ae
Add date-filterable balance contribution view (#604)
krokosik 3580758
Add typed balance queries and seeded regression baselines (#604)
krokosik 95ed410
Apply viewer-local date filters to balance APIs (#604)
krokosik e6c3257
Ignore local seeded balance baseline snapshot
krokosik File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,6 +64,7 @@ certificates | |
| .vscode | ||
|
|
||
| SEED_STATISTICS.md | ||
| /scripts/seeded-balance-baseline.json | ||
|
|
||
| # Agents | ||
| .worktrees/ | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
prisma/migrations/20261019220000_date_filterable_balances/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| -- #604: filter expense-date contributions before reducing balances. | ||
| CREATE VIEW public."BalanceEntryView" AS | ||
| SELECT ep."expenseId", ep."userId", e."paidBy" AS "friendId", e."groupId", | ||
| e.currency, ep.amount, e."expenseDate", e."createdAt", e."updatedAt" | ||
| FROM public."ExpenseParticipant" ep | ||
| JOIN public."Expense" e ON e.id = ep."expenseId" | ||
| WHERE ep."userId" <> e."paidBy" AND e."deletedAt" IS NULL | ||
| UNION ALL | ||
| SELECT ep."expenseId", e."paidBy", ep."userId", e."groupId", | ||
| e.currency, -ep.amount, e."expenseDate", e."createdAt", e."updatedAt" | ||
| FROM public."ExpenseParticipant" ep | ||
| JOIN public."Expense" e ON e.id = ep."expenseId" | ||
| WHERE ep."userId" <> e."paidBy" AND e."deletedAt" IS NULL; | ||
|
|
||
| CREATE INDEX "ExpenseParticipant_userId_expenseId_idx" | ||
| ON public."ExpenseParticipant" ("userId", "expenseId") INCLUDE (amount); | ||
|
|
||
| CREATE INDEX "Expense_expenseDate_live_idx" | ||
| ON public."Expense" ("expenseDate") WHERE "deletedAt" IS NULL; | ||
5 changes: 5 additions & 0 deletions
5
prisma/migrations/20261019223000_prisma_managed_balance_index/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| -- Prisma cannot represent PostgreSQL INCLUDE indexes. Keep the same covering | ||
| -- access path with amount as the final key so future migrations retain it. | ||
| CREATE INDEX "ExpenseParticipant_userId_expenseId_amount_idx" | ||
| ON public."ExpenseParticipant" ("userId", "expenseId", amount); | ||
| DROP INDEX public."ExpenseParticipant_userId_expenseId_idx"; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| SELECT ep."expenseId", ep."userId", e."paidBy" AS "friendId", e."groupId", | ||
| e.currency, ep.amount, e."expenseDate", e."createdAt", e."updatedAt" | ||
| FROM public."ExpenseParticipant" ep | ||
| JOIN public."Expense" e ON e.id = ep."expenseId" | ||
| WHERE ep."userId" <> e."paidBy" AND e."deletedAt" IS NULL | ||
| UNION ALL | ||
| SELECT ep."expenseId", e."paidBy", ep."userId", e."groupId", | ||
| e.currency, -ep.amount, e."expenseDate", e."createdAt", e."updatedAt" | ||
| FROM public."ExpenseParticipant" ep | ||
| JOIN public."Expense" e ON e.id = ep."expenseId" | ||
| WHERE ep."userId" <> e."paidBy" AND e."deletedAt" IS NULL; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| import { type BalanceView, type Prisma, PrismaClient } from '@prisma/client'; | ||
| import assert from 'node:assert/strict'; | ||
| import { createHash } from 'node:crypto'; | ||
| import { readFile, writeFile } from 'node:fs/promises'; | ||
| import { parseArgs } from 'node:util'; | ||
| import { z } from 'zod'; | ||
|
|
||
| import { createDatabaseAdapter } from '../src/server/databaseAdapter'; | ||
|
|
||
| const { values } = parseArgs({ | ||
| options: { | ||
| capture: { type: 'boolean', default: false }, | ||
| entries: { type: 'boolean', default: false }, | ||
| snapshot: { type: 'string', default: 'scripts/seeded-balance-baseline.json' }, | ||
| }, | ||
| }); | ||
| assert(!(values.capture && values.entries), 'Capture the legacy implementation before migration'); | ||
| const databaseUrl = process.env.DATABASE_URL; | ||
| assert( | ||
| databaseUrl && ['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname), | ||
| 'Use the local seeded database', | ||
| ); | ||
| const db = new PrismaClient({ adapter: createDatabaseAdapter(databaseUrl) }); | ||
| const timings: { scope: string; ms: number }[] = []; | ||
| const snapshotSchema = z.object({ | ||
| capturedAt: z.string(), | ||
| expenses: z.number(), | ||
| participants: z.number(), | ||
| queries: z.array( | ||
| z.object({ | ||
| name: z.string(), | ||
| where: z.object({ | ||
| userId: z.number().optional(), | ||
| friendId: z.number().optional(), | ||
| groupId: z.number().optional(), | ||
| }), | ||
| rows: z.number(), | ||
| digest: z.string(), | ||
| }), | ||
| ), | ||
| }); | ||
| const normalize = (rows: BalanceView[]) => | ||
| rows | ||
| .map((row) => ({ | ||
| userId: row.userId, | ||
| friendId: row.friendId, | ||
| groupId: row.groupId, | ||
| currency: row.currency, | ||
| amount: row.amount.toString(), | ||
| createdAt: row.createdAt.toISOString(), | ||
| updatedAt: row.updatedAt.toISOString(), | ||
| })) | ||
| .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); | ||
| const digest = (rows: BalanceView[]) => | ||
| createHash('sha256') | ||
| .update(JSON.stringify(normalize(rows))) | ||
| .digest('hex'); | ||
|
|
||
| try { | ||
| const saved = values.capture | ||
| ? undefined | ||
| : snapshotSchema.parse(JSON.parse(await readFile(values.snapshot, 'utf8'))); | ||
| // Load the new service only when explicitly requested, after its migration exists. | ||
| const queryEntries = values.entries | ||
| ? (await import('../src/server/api/services/balanceService')).getBalances | ||
| : undefined; | ||
| const snapshot = await db.$transaction( | ||
| async (tx) => { | ||
| await tx.$executeRaw`SET TRANSACTION READ ONLY`; | ||
| const [expenses, participants, users, groups] = await Promise.all([ | ||
| tx.expense.count(), | ||
| tx.expenseParticipant.count(), | ||
| tx.user.findMany({ select: { id: true } }), | ||
| tx.group.findMany({ select: { id: true } }), | ||
| ]); | ||
| const all = await tx.balanceView.findMany(); | ||
| assert(all.length, 'Seed the database before capturing balances'); | ||
| const pairs = [ | ||
| ...new Map( | ||
| all.map((row) => [ | ||
| `${row.userId}/${row.friendId}`, | ||
| { userId: row.userId, friendId: row.friendId }, | ||
| ]), | ||
| ).values(), | ||
| ] | ||
| .sort((a, b) => a.userId - b.userId || a.friendId - b.friendId) | ||
| .slice(0, 50); | ||
| const queries = saved?.queries ?? [ | ||
| { name: 'all', where: {}, rows: 0, digest: '' }, | ||
| ...users.map(({ id }) => ({ | ||
| name: `user/${id}`, | ||
| where: { userId: id }, | ||
| rows: 0, | ||
| digest: '', | ||
| })), | ||
| ...groups.map(({ id }) => ({ | ||
| name: `group/${id}`, | ||
| where: { groupId: id }, | ||
| rows: 0, | ||
| digest: '', | ||
| })), | ||
| ...pairs.map((where) => ({ | ||
| name: `pair/${where.userId}/${where.friendId}`, | ||
| where, | ||
| rows: 0, | ||
| digest: '', | ||
| })), | ||
| ]; | ||
| const results = await queries.reduce<Promise<typeof queries>>(async (pending, query) => { | ||
| const previous = await pending; | ||
| const where: Prisma.BalanceViewWhereInput = query.where; | ||
| const started = performance.now(); | ||
| const rows = queryEntries | ||
| ? await queryEntries(tx, { where: query.where }) | ||
| : await tx.balanceView.findMany({ where }); | ||
| timings.push({ scope: query.name.split('/')[0]!, ms: performance.now() - started }); | ||
| const result = { ...query, rows: rows.length, digest: digest(rows) }; | ||
| if (saved) { | ||
| assert.deepEqual(result, query, `Balance baseline changed: ${query.name}`); | ||
| } | ||
| return [...previous, result]; | ||
| }, Promise.resolve([])); | ||
|
|
||
| // Independent arithmetic oracle: no balance SQL/view is used to construct it. | ||
| const records = await tx.expense.findMany({ | ||
| where: { deletedAt: null }, | ||
| include: { expenseParticipants: true }, | ||
| }); | ||
| const oracle = new Map<string, BalanceView>(); | ||
| records.forEach((expense) => | ||
| expense.expenseParticipants.forEach((participant) => { | ||
| if (participant.userId === expense.paidBy) { | ||
| return; | ||
| } | ||
| [ | ||
| { userId: participant.userId, friendId: expense.paidBy, amount: participant.amount }, | ||
| { userId: expense.paidBy, friendId: participant.userId, amount: -participant.amount }, | ||
| ].forEach((entry) => { | ||
| const key = `${entry.userId}/${entry.friendId}/${expense.groupId}/${expense.currency}`; | ||
| const existing = oracle.get(key); | ||
| oracle.set(key, { | ||
| ...entry, | ||
| groupId: expense.groupId, | ||
| currency: expense.currency, | ||
| amount: (existing?.amount ?? 0n) + entry.amount, | ||
| createdAt: | ||
| existing && existing.createdAt < expense.createdAt | ||
| ? existing.createdAt | ||
| : expense.createdAt, | ||
| updatedAt: | ||
| existing && existing.updatedAt > expense.updatedAt | ||
| ? existing.updatedAt | ||
| : expense.updatedAt, | ||
| }); | ||
| }); | ||
| }), | ||
| ); | ||
| assert.deepEqual( | ||
| normalize(all), | ||
| normalize([...oracle.values()]), | ||
| 'Legacy view differs from participant arithmetic', | ||
| ); | ||
| assert.equal( | ||
| all.reduce((sum, row) => sum + row.amount, 0n), | ||
| 0n, | ||
| 'Mirrored amounts must cancel', | ||
| ); | ||
| if (saved) { | ||
| assert.equal(expenses, saved.expenses, 'Seeded expense count changed'); | ||
| assert.equal(participants, saved.participants, 'Seeded participant count changed'); | ||
| } | ||
| return { | ||
| capturedAt: saved?.capturedAt ?? new Date().toISOString(), | ||
| expenses, | ||
| participants, | ||
| queries: results, | ||
| }; | ||
| }, | ||
| { timeout: 120_000, isolationLevel: 'RepeatableRead' }, | ||
| ); | ||
| if (values.capture) { | ||
| await writeFile(values.snapshot, `${JSON.stringify(snapshot, null, 2)}\n`, { flag: 'wx' }); | ||
| } | ||
| console.info( | ||
| `${snapshot.queries.length} seeded balance query baselines passed (${values.entries ? 'entries service' : 'legacy view'}); independent monetary/metadata oracle passed.`, | ||
| ); | ||
| console.table( | ||
| ['all', 'user', 'group', 'pair'].map((scope) => { | ||
| const samples = timings | ||
| .filter((timing) => timing.scope === scope) | ||
| .map((timing) => timing.ms) | ||
| .sort((a, b) => a - b); | ||
| return { | ||
| scope, | ||
| queries: samples.length, | ||
| medianMs: Number(samples[Math.floor(samples.length / 2)]!.toFixed(3)), | ||
| }; | ||
| }), | ||
| ); | ||
| } finally { | ||
| await db.$disconnect(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { useEffect } from 'react'; | ||
|
|
||
| import { getBalanceDay } from '~/lib/balanceDate'; | ||
| import { api } from '~/utils/api'; | ||
|
|
||
| /** Header-derived dates do not change query keys, so invalidate on scope changes. */ | ||
| export const useBalanceDateRefresh = () => { | ||
| const utils = api.useUtils(); | ||
| useEffect(() => { | ||
| const current = () => getBalanceDay(Intl.DateTimeFormat().resolvedOptions().timeZone); | ||
| let scope = current(); | ||
| let timer: ReturnType<typeof setTimeout> | undefined = undefined; | ||
| const refresh = () => { | ||
| const next = current(); | ||
| if (scope.day !== next.day || scope.timeZone !== next.timeZone) { | ||
| Promise.all([ | ||
| utils.expense.getBalances.invalidate(), | ||
| utils.expense.getCumulatedBalances.invalidate(), | ||
| utils.user.getBalancesWithFriend.invalidate(), | ||
| utils.group.getAllGroupsWithBalances.invalidate(), | ||
| utils.group.getGroupDetails.invalidate(), | ||
| ]).catch(console.error); | ||
| } | ||
| scope = next; | ||
| clearTimeout(timer); | ||
| timer = setTimeout(refresh, Math.max(1, scope.toDateExclusive.getTime() - Date.now())); | ||
| }; | ||
| refresh(); | ||
| window.addEventListener('focus', refresh); | ||
| document.addEventListener('visibilitychange', refresh); | ||
| return () => { | ||
| clearTimeout(timer); | ||
| window.removeEventListener('focus', refresh); | ||
| document.removeEventListener('visibilitychange', refresh); | ||
| }; | ||
| }, [utils]); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| export const BALANCE_TIME_ZONE_HEADER = 'x-splitpro-time-zone'; | ||
|
|
||
| export const getBalanceDay = ( | ||
| timeZone = 'UTC', | ||
| now = new Date(), | ||
| ): { | ||
| timeZone: string; | ||
| day: string; | ||
| toDateExclusive: Date; | ||
| } => { | ||
| const formatter = (() => { | ||
| try { | ||
| return new Intl.DateTimeFormat('en-CA', { | ||
| timeZone, | ||
| year: 'numeric', | ||
| month: '2-digit', | ||
| day: '2-digit', | ||
| }); | ||
| } catch { | ||
| return new Intl.DateTimeFormat('en-CA', { | ||
| timeZone: 'UTC', | ||
| year: 'numeric', | ||
| month: '2-digit', | ||
| day: '2-digit', | ||
| }); | ||
| } | ||
| })(); | ||
| const day = formatter.format(now); | ||
| // Search for the next calendar-day boundary rather than adding 24 hours. | ||
| // This also handles offset changes at midnight and skipped calendar dates. | ||
| const [, end] = Array.from({ length: 28 }).reduce<[number, number]>( | ||
| ([start, finish]) => { | ||
| if (1 >= finish - start) { | ||
| return [start, finish]; | ||
| } | ||
| const middle = Math.floor((start + finish) / 2); | ||
| return formatter.format(new Date(middle)) === day ? [middle, finish] : [start, middle]; | ||
| }, | ||
| [now.getTime(), now.getTime() + 48 * 60 * 60 * 1000], | ||
| ); | ||
| return { | ||
| timeZone: formatter.resolvedOptions().timeZone, | ||
| day, | ||
| toDateExclusive: new Date(end), | ||
| }; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: oss-apps/split-pro
Length of output: 433
🏁 Script executed:
Repository: oss-apps/split-pro
Length of output: 13099
🌐 Web query:
Prisma ORM 7.10.1 PostgreSQL partial indexes Prisma Migrate manual partial index drift drop💡 Result:
🌐 Web query:
site:prisma.io/docs Prisma Migrate schema drift manually created index not in schema DROP INDEX migrate dev partial index where raw exact syntax💡 Result:
Declare the partial index in
schema.prisma.The migration creates
Expense_expenseDate_live_idx, but the schema does not declare it. Prisma 7.10.1 supports PostgreSQL partial indexes throughpartialIndexes; without that declaration, a futureprisma migrate devcan remove the unmanaged index.Suggested fix
🧰 Tools
🪛 Squawk (2.66.0)
[warning] 18-19: During normal index creation, table updates are blocked, but reads are still allowed. Use
concurrentlyto avoid blocking writes.(require-concurrent-index-creation)
🤖 Prompt for AI Agents