Skip to content

fix: harden cloud balance integrity - #709

Open
KMKoushik wants to merge 4 commits into
cloudfrom
fix/cloud-balance-integrity
Open

fix: harden cloud balance integrity#709
KMKoushik wants to merge 4 commits into
cloudfrom
fix/cloud-balance-integrity

Conversation

@KMKoushik

@KMKoushik KMKoushik commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Make exact friendship settlements clear group projections through linked, reversible allocations instead of destructive zeroing.
  • Keep partial friendship payments separate from group ledgers, matching Splitwise-style accounting contexts.
  • Conserve integer cents deterministically across all editable split types and validate/normalize participant payloads at the server boundary.
  • Reconstruct shares correctly when changing split type and hydrate edit state atomically, including category and attachment.
  • Exclude settlements from group spending totals and show the dominant group-balance currency with a multi-currency marker.
  • Add a lightweight Node test suite plus PostgreSQL integration coverage for balance and settlement lifecycles.

Settlement projection

An exact non-group SETTLEMENT now snapshots every affected directional GroupBalance row into SettlementAllocation before clearing it. The allocation is linked to the settlement expense and contains the exact amount needed to reverse the projection.

  • Editing an exact settlement to a partial payment restores the linked group amounts.
  • Deleting a settlement restores allocations additively, preserving group activity recorded after the settlement.
  • Financially unchanged edits retain their allocations instead of recalculating against later activity.
  • Partial friendship payments and ordinary personal expenses do not alter group ledgers.
  • Different currencies and unrelated friends remain isolated.
  • If a group was deleted or a member left after settlement, its allocation is detached during reversal and the debt returns to the friendship ledger without recreating removed group state.
  • Existing settlements are marked as legacy by the additive migration and cannot be edited or deleted because their destructively cleared group amounts cannot be reconstructed safely.

Balance-changing settlement operations, group expense creation/editing, group leave/delete, and friend deletion use serializable transactions with bounded retries. Group expense participants are revalidated inside the transaction so lifecycle checks cannot race allocation restoration or create inaccessible debt after a member leaves.

Other integrity fixes

  • Scope group projection changes to the correct pair and currency.
  • Correct one-cent split drift such as USD 10 / 3 and normalize bounded drift from old clients.
  • Reject malformed participant payloads, missing/duplicate payers, and invalid debt directions.
  • Reject zero and non-finite SHARE totals without entering the cent-correction loop.
  • Preserve edit category and attachment state.
  • Exclude SETTLEMENT from group spending totals.
  • Select the largest absolute nonzero group-summary currency and indicate multiple currencies.

Verification

  • pnpm test: 23 unit tests pass; 2 PostgreSQL tests skip unless TEST_DATABASE_URL is set.
  • PostgreSQL 16 integration run: all 25 tests pass.
  • Fresh database: all 8 cloud migrations apply successfully.
  • pnpm exec tsc --noEmit: pass.
  • SKIP_ENV_VALIDATION=1 pnpm lint: pass with existing branch warnings only.
  • SKIP_ENV_VALIDATION=1 pnpm build: pass.
  • Prisma validation, Prettier, and git diff --check: pass.

No historical balance repair is included. The migration is additive and preserves legacy settlement provenance conservatively.

Summary by CodeRabbit

  • New Features
    • Settlement expenses now preserve allocation details, allowing balances to be restored when settlements are edited or deleted.
    • Added improved support for personal and group expense processing, including safer balance updates.
    • Group balances now identify multi-currency groups with a visual indicator.
  • Bug Fixes
    • Improved split calculations to prevent rounding discrepancies and reject invalid amounts.
    • Group and friend balance changes are now handled more reliably during leave, deletion, and expense updates.
  • Tests
    • Added coverage for settlement restoration, balance summaries, and split accuracy.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds settlement allocation persistence, transactional balance reconciliation, participant normalization, multi-currency balance summaries, updated expense flows, and unit/integration test coverage. Group and friend balance mutations now use serializable transactions with retry handling.

Changes

Settlement and balance flow

Layer / File(s) Summary
Allocation storage and transaction retry
prisma/schema.prisma, prisma/migrations/..., src/server/api/services/balanceTransaction.ts, src/server/api/services/balanceProjection.ts
Adds settlement allocation records and transaction helpers for capturing and restoring group balances.
Split normalization and cent conservation
src/utils/splits.ts, src/store/addStore.ts, src/tests/splits.test.ts
Validates participant inputs, corrects bounded rounding drift, handles invalid shares, and preserves cent totals across split types.
Personal and group expense reconciliation
src/server/api/services/splitService.ts, src/tests/balanceProjection.integration.test.ts
Routes personal and group expense operations through transactional balance updates, settlement allocation capture/restoration, membership checks, and edit/delete reconciliation.
Balance filtering, API transactions, and display
src/utils/balances.ts, src/server/api/routers/*, src/pages/add.tsx, src/pages/groups.tsx, src/tests/balances.test.ts, package.json
Centralizes balance filtering and summaries, updates router transactions and group display behavior, simplifies edit initialization, and adds the test script.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant splitService
  participant runBalanceTransaction
  participant balanceProjection
  participant Database
  Client->>splitService: create or edit settlement expense
  splitService->>runBalanceTransaction: execute balance operation
  runBalanceTransaction->>Database: begin serializable transaction
  splitService->>balanceProjection: capture or restore allocations
  balanceProjection->>Database: update group balances and allocations
  splitService->>Database: persist expense and participant balances
  Database-->>Client: return expense result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: hardening cloud balance integrity.
Description check ✅ Passed The description is detailed and covers the required summary and verification; only the Demo and Checklist template sections are missing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cloud-balance-integrity

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

package.json

Parsing error: ESLint was configured to run on <tsconfigRootDir>/package.json using parserOptions.project: /tsconfig.json
The extension for the file (.json) is non-standard. You should add parserOptions.extraFileExtensions to your config.


Comment @coderabbitai help to get the list of available commands.

@KMKoushik

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (7)
src/tests/splits.test.ts (2)

20-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The PERCENTAGE fixture depends on floating-point luck.

33.33 + 33.33 + 33.34 is compared against 100 with strict equality inside calculateParticipantSplit, so this case passes only if the accumulation happens to land exactly on 100. Adding a 4-participant or uneven-percentage case here would surface the fragility flagged on src/store/addStore.ts Line 338.

🤖 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/tests/splits.test.ts` around lines 20 - 26, The PERCENTAGE case in the
cases fixture relies on strict floating-point accumulation equaling 100. Update
the test fixture to include a four-participant or uneven-percentage scenario
that exposes this precision issue, while preserving coverage of
calculateParticipantSplit and the existing split types.

129-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Seed canSplitScreenClosed: false so Line 149 actually proves something.

setState leaves canSplitScreenClosed at its store default of true, so that assertion would pass even if setSplitType never recomputed it. The store is also global and never reset, so this state leaks into any test added after it.

💚 Suggested change
     useAddExpenseStore.setState({
       amount: 10,
       paidBy: payer,
       splitType: SplitType.EQUAL,
+      canSplitScreenClosed: false,
       participants: [
🤖 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/tests/splits.test.ts` around lines 129 - 150, Update the test setup for
setSplitType in the “reconstructs percentage shares…” case to explicitly
initialize canSplitScreenClosed to false, ensuring the assertion verifies that
setSplitType recomputes it to true. Reset or otherwise isolate the global
useAddExpenseStore state after this test so its seeded state cannot leak into
subsequent tests.
src/tests/balanceProjection.integration.test.ts (2)

157-160: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Six independent scenarios in one test() under a name that describes only the first.

Partial settlement, metadata-only edit, deletion, legacy-settlement rejection, departed-member handling, and group-deletion handling all share one test body, so the first failure hides the rest and the failure message won't say which scenario broke. Consider describe + subtests over the shared fixture.

🤖 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/tests/balanceProjection.integration.test.ts` around lines 157 - 160,
Split the single test body currently named “restores group balances when a
completed personal settlement becomes partial” into six independently named
subtests, covering partial settlement, metadata-only edit, deletion,
legacy-settlement rejection, departed-member handling, and group-deletion
handling. Use a surrounding describe or shared fixture setup so each subtest
runs independently and reports its specific scenario on failure.

151-153: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cleanup only runs on success, so a failing assertion leaks fixtures into the test database.

Every assert above this block can abort the test and skip the teardown, leaving users, groups, expenses, and allocations behind for every subsequent run. t.after() (or try/finally) makes teardown unconditional. Same applies to Lines 511-513.

💚 Suggested change
-  async () => {
+  async (t) => {
+    t.after(async () => {
+      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();
+    });

(hoisting the ids so they are in scope, and dropping the trailing teardown block)

🤖 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/tests/balanceProjection.integration.test.ts` around lines 151 - 153, Make
teardown unconditional in the affected integration tests by moving the cleanup
currently following the assertions into a t.after() hook or try/finally block.
Hoist the created fixture IDs so cleanup can access them even when assertions
fail, and apply the same change to the teardown near the second referenced
block; preserve deletion of expenses, allocations, groups, users, and database
disconnection.
src/store/addStore.ts (1)

257-309: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Cent-conservation pass reads well; one subtlety worth a comment.

participantsToAdjust is captured from the pre-inversion amounts, so it means "participants with a non-zero share" rather than "participants with a non-zero final amount". That is the right set for spreading rounding cents, but it is easy to misread two blocks later. A one-line comment would save the next reader the trace.

The Number.isFinite(imbalance) check at Line 288 is already covered by the guard at Line 269 — harmless, but redundant.

🤖 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/store/addStore.ts` around lines 257 - 309, The cent-conservation logic
needs clarification and minor cleanup. Add a concise comment by
participantsToAdjust explaining that it intentionally uses pre-inversion
non-zero shares as the correction set, then remove the redundant
Number.isFinite(imbalance) guard inside the canSplitScreenClosed block while
preserving the earlier finite-amount validation.
src/server/api/services/splitService.ts (1)

482-483: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

operations is inferred as any[], so result[0] as Expense | undefined is unchecked.

Awaiting the expense creation directly gives you a real Expense and drops the cast, while the balance upserts can still go through Promise.all.

♻️ Suggested change
-    const result = await Promise.all(operations);
-    return result[0] as Expense | undefined;
+    const [createdExpense] = await Promise.all([expenseCreate, ...operations]);
+    return createdExpense;

(with expenseCreate held in its own variable instead of being pushed into operations)

🤖 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/server/api/services/splitService.ts` around lines 482 - 483, Update the
operation flow near the expense creation and result return so the expense
creation promise is stored separately and awaited directly, yielding a typed
Expense without casting. Keep only the balance upsert promises in operations and
continue awaiting them with Promise.all before returning the created expense.
src/server/api/services/balanceTransaction.ts (1)

12-14: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Set explicit maxWait/timeout here src/server/db.ts doesn’t configure transactionOptions, so this interactive transaction still inherits Prisma’s 2s/5s defaults and can fail with P2028 under load. If the retry loop stays, add jitter to the backoff to avoid synchronized retries.

♻️ Suggested change
     return await db.$transaction(operation, {
       isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
+      maxWait: 5_000,
+      timeout: 15_000,
     });
🤖 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/server/api/services/balanceTransaction.ts` around lines 12 - 14, Update
the transaction invocation in the balance transaction service to set explicit
maxWait and timeout values suitable for this operation, avoiding Prisma’s
default limits. If the surrounding retry loop remains, add randomized jitter to
its backoff while preserving the existing retry behavior.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/pages/add.tsx`:
- Around line 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.

In `@src/server/api/services/balanceProjection.ts`:
- Around line 115-143: Change the cleanup in the balance projection flow to
delete only the settlement allocations included in restorableAllocations, rather
than every row matching settlementExpenseId. Preserve allocations excluded by
the membership filter so their provenance remains available for later
reconciliation, and update the affected integration-test expectation
accordingly.

In `@src/server/api/services/splitService.ts`:
- Around line 538-543: The group-expense deletion branch in the delete flow must
gain the same concurrency protection as deletePersonalExpense. Update the group
path around deletePersonalExpense to use runBalanceTransaction, re-read the
expense inside the serializable transaction, and reject or no-op when deletedAt
is already set before reversing balances or marking the expense deleted.

In `@src/store/addStore.ts`:
- Around line 338-339: Update the percentage calculation in
calculateParticipantSplit so independently rounded participant shares cannot
leave the total invalid for a multi-participant split; assign the rounding
residual to the payer, ensuring the resulting shares sum to exactly 100 while
preserving the existing percentage values for other participants.

In `@src/utils/splits.ts`:
- Around line 8-22: Update the participant normalization and validation flow
around storedParticipants to reject any non-finite converted amount, including
NaN and Infinity, before payer, balance, or transaction processing occurs. Use
the existing validation error pattern and preserve the current handling of valid
finite amounts.

---

Nitpick comments:
In `@src/server/api/services/balanceTransaction.ts`:
- Around line 12-14: Update the transaction invocation in the balance
transaction service to set explicit maxWait and timeout values suitable for this
operation, avoiding Prisma’s default limits. If the surrounding retry loop
remains, add randomized jitter to its backoff while preserving the existing
retry behavior.

In `@src/server/api/services/splitService.ts`:
- Around line 482-483: Update the operation flow near the expense creation and
result return so the expense creation promise is stored separately and awaited
directly, yielding a typed Expense without casting. Keep only the balance upsert
promises in operations and continue awaiting them with Promise.all before
returning the created expense.

In `@src/store/addStore.ts`:
- Around line 257-309: The cent-conservation logic needs clarification and minor
cleanup. Add a concise comment by participantsToAdjust explaining that it
intentionally uses pre-inversion non-zero shares as the correction set, then
remove the redundant Number.isFinite(imbalance) guard inside the
canSplitScreenClosed block while preserving the earlier finite-amount
validation.

In `@src/tests/balanceProjection.integration.test.ts`:
- Around line 157-160: Split the single test body currently named “restores
group balances when a completed personal settlement becomes partial” into six
independently named subtests, covering partial settlement, metadata-only edit,
deletion, legacy-settlement rejection, departed-member handling, and
group-deletion handling. Use a surrounding describe or shared fixture setup so
each subtest runs independently and reports its specific scenario on failure.
- Around line 151-153: Make teardown unconditional in the affected integration
tests by moving the cleanup currently following the assertions into a t.after()
hook or try/finally block. Hoist the created fixture IDs so cleanup can access
them even when assertions fail, and apply the same change to the teardown near
the second referenced block; preserve deletion of expenses, allocations, groups,
users, and database disconnection.

In `@src/tests/splits.test.ts`:
- Around line 20-26: The PERCENTAGE case in the cases fixture relies on strict
floating-point accumulation equaling 100. Update the test fixture to include a
four-participant or uneven-percentage scenario that exposes this precision
issue, while preserving coverage of calculateParticipantSplit and the existing
split types.
- Around line 129-150: Update the test setup for setSplitType in the
“reconstructs percentage shares…” case to explicitly initialize
canSplitScreenClosed to false, ensuring the assertion verifies that setSplitType
recomputes it to true. Reset or otherwise isolate the global useAddExpenseStore
state after this test so its seeded state cannot leak into subsequent tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c4919379-5ee8-49af-8d5e-56638d0263bc

📥 Commits

Reviewing files that changed from the base of the PR and between eaee309 and d9070ce.

📒 Files selected for processing (16)
  • package.json
  • prisma/migrations/20260726000000_add_settlement_allocations/migration.sql
  • prisma/schema.prisma
  • src/pages/add.tsx
  • src/pages/groups.tsx
  • src/server/api/routers/group.ts
  • src/server/api/routers/user.ts
  • src/server/api/services/balanceProjection.ts
  • src/server/api/services/balanceTransaction.ts
  • src/server/api/services/splitService.ts
  • src/store/addStore.ts
  • src/tests/balanceProjection.integration.test.ts
  • src/tests/balances.test.ts
  • src/tests/splits.test.ts
  • src/utils/balances.ts
  • src/utils/splits.ts

Comment thread src/pages/add.tsx
Comment on lines +79 to 87
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,
);

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.

Comment on lines +115 to +143
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 } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

deleteMany drops allocations that were never restored, losing both the money and the provenance.

restorableAllocations filters out allocations whose participants are no longer group members (or whose group was deleted), but Line 143 deletes every allocation for the expense. The result: the group balance stays zeroed, the overall friend balance is restored, and the record explaining the discrepancy is gone — so the group ledger can never be reconciled, even if the user rejoins. This contradicts the schema comment on groupId about deleting a group keeping settlement provenance.

Deleting only what was actually restored keeps the unresolved rows auditable (and reapplies them if membership is later regained).

🛡️ Proposed fix
   await Promise.all(
     restorableAllocations.map((allocation) =>
       tx.groupBalance.upsert({
@@
     ),
   );
-  await tx.settlementAllocation.deleteMany({ where: { settlementExpenseId } });
+  await Promise.all(
+    restorableAllocations.map((allocation) =>
+      tx.settlementAllocation.delete({
+        where: {
+          settlementExpenseId_groupId_currency_friendId_userId: {
+            settlementExpenseId,
+            groupId: allocation.groupId,
+            currency: allocation.currency,
+            friendId: allocation.friendId,
+            userId: allocation.userId,
+          },
+        },
+      }),
+    ),
+  );

Note this changes the expectation asserted in src/tests/balanceProjection.integration.test.ts Line 509 (detachedAllocations.length === 0).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 } });
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 Promise.all(
restorableAllocations.map((allocation) =>
tx.settlementAllocation.delete({
where: {
settlementExpenseId_groupId_currency_friendId_userId: {
settlementExpenseId,
groupId: allocation.groupId,
currency: allocation.currency,
friendId: allocation.friendId,
userId: allocation.userId,
},
},
}),
),
);
🤖 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/server/api/services/balanceProjection.ts` around lines 115 - 143, Change
the cleanup in the balance projection flow to delete only the settlement
allocations included in restorableAllocations, rather than every row matching
settlementExpenseId. Preserve allocations excluded by the membership filter so
their provenance remains available for later reconciliation, and update the
affected integration-test expectation accordingly.

Comment on lines +538 to +543
if (null === expense.groupId) {
await deletePersonalExpense(expenseId, deletedBy);
sendExpensePushNotification(expenseId).catch(console.error);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Group deletions lost the deletedAt guard that the personal path just gained.

deletePersonalExpense re-reads the expense inside a serializable transaction and rejects an already-deleted expense (Line 303). The group branch below still reads expense outside any transaction at Line 523 and never checks expense.deletedAt, so two concurrent (or simply repeated) delete calls on a group expense reverse the balances twice and leave the group ledger permanently wrong.

Since the personal path is now hardened, this asymmetry is the weakest link in the delete flow. Moving the group branch onto runBalanceTransaction with the same re-read + deletedAt check would close it.

🤖 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/server/api/services/splitService.ts` around lines 538 - 543, The
group-expense deletion branch in the delete flow must gain the same concurrency
protection as deletePersonalExpense. Update the group path around
deletePersonalExpense to use runBalanceTransaction, re-read the expense inside
the serializable transaction, and reject or no-op when deletedAt is already set
before reversing balances or marking the expense deleted.

Comment thread src/store/addStore.ts
Comment on lines +338 to +339
? Math.round((Math.abs(p.amount ?? 0) / amount) * 10000) / 100
: Math.round((Math.abs(amount - (p.amount ?? 0)) / amount) * 10000) / 100,

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 | 🟠 Major | ⚡ Quick win

Rounding percentages to 2 dp can leave the split screen unclosable after a split-type switch.

calculateParticipantSplit's PERCENTAGE branch gates on 100 - sum(splitShare) === 0 (Line 226) — an exact float comparison. The shares produced here are rounded independently per participant, so for a 3-way split of 10 the reconstruction gives 33.3 / 33.3 / 33.4, whose sum is subject to binary floating-point error and may not compare exactly equal to 100. The user then flips EQUAL → PERCENTAGE on a perfectly valid expense and gets stuck with the split screen refusing to close.

Two options: assign the residual to the payer so the shares sum to exactly 100 by construction, or relax the gate to an epsilon comparison as the EXACT branch already does.

The existing test only covers the 2-participant 50/50 case, which cannot expose this.

🐛 Suggested epsilon gate (companion fix, outside this range)
-      canSplitScreenClosed =
-        100 - participants.reduce((acc, p) => acc + (p.splitShare ?? 0), 0) === 0;
+      canSplitScreenClosed =
+        Math.abs(100 - participants.reduce((acc, p) => acc + (p.splitShare ?? 0), 0)) < 0.01;
🤖 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/store/addStore.ts` around lines 338 - 339, Update the percentage
calculation in calculateParticipantSplit so independently rounded participant
shares cannot leave the total invalid for a multi-participant split; assign the
rounding residual to the payer, ensuring the resulting shares sum to exactly 100
while preserving the existing percentage values for other participants.

Comment thread src/utils/splits.ts
Comment on lines +8 to +22
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');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

NaN amounts bypass every validation in this function.

toInteger(NaN) is NaN, and each subsequent check silently accepts it:

  • storedParticipants.length < Math.abs(NaN)false, so the balance guard does not fire.
  • payer.amount -= NaNNaN.
  • In hasInvalidParticipant, NaN < 0, storedAmount < NaN, and 0 < NaN are all false.

So a non-finite participant amount (or a non-finite amount) flows straight into expenseParticipant.create / balance increments inside the transaction, where it fails as an opaque Prisma error — or, worse, corrupts a balance. An explicit finiteness check up front is the cheap fix. (Note Infinity happens to throw the "must balance to zero" message, which is also misleading.)

🛡️ Proposed fix
 ) => {
+  if (!Number.isFinite(amount) || participants.some((p) => !Number.isFinite(p.amount))) {
+    throw new Error('Expense amounts must be finite numbers');
+  }
+
   const storedParticipants = participants.map((participant) => ({
     userId: participant.userId,
     amount: toInteger(participant.amount),
   }));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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');
}
if (!Number.isFinite(amount) || participants.some((p) => !Number.isFinite(p.amount))) {
throw new Error('Expense amounts must be finite numbers');
}
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');
}
🤖 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/utils/splits.ts` around lines 8 - 22, Update the participant
normalization and validation flow around storedParticipants to reject any
non-finite converted amount, including NaN and Infinity, before payer, balance,
or transaction processing occurs. Use the existing validation error pattern and
preserve the current handling of valid finite amounts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant