From 112a6c084adc4ee9d2fbc719aa6c2d6be5180f8a Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 14 Oct 2025 12:20:44 +0200 Subject: [PATCH 1/5] Fixed Bug with left over pennies reshuffling + added tests --- src/store/addStore.test.ts | 239 ++++++++++++++++++++++++++++++------- src/store/addStore.ts | 51 +++++++- 2 files changed, 241 insertions(+), 49 deletions(-) diff --git a/src/store/addStore.test.ts b/src/store/addStore.test.ts index 860eed28..75f55703 100644 --- a/src/store/addStore.test.ts +++ b/src/store/addStore.test.ts @@ -128,22 +128,6 @@ describe('calculateParticipantSplit', () => { expect(totalOwed).toBe(0n); // Total should always balance // Payer should get the remainder expect(result.participants[0]?.amount).toBeGreaterThanOrEqual(6667n); }); - - it('should handle single participant as payer', () => { - const participants = createParticipants([user1]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(0n); // Payer pays and owes nothing - expect(result.canSplitScreenClosed).toBe(true); - }); }); describe('SplitType.PERCENTAGE', () => { @@ -460,6 +444,188 @@ describe('calculateParticipantSplit', () => { }); }); +describe('calculateParticipantSplit leftover penny distribution', () => { + // Mock participants that we'll use across tests + const participants: Participant[] = [ + createMockUser(1, 'Alice', 'alice@test.com'), + createMockUser(2, 'Bob', 'bob@test.com'), + createMockUser(3, 'Charlie', 'charlie@test.com') + ]; + + // Helper to get split shares for equal split + const getEqualSplitShares = (participants: Participant[]): Record> => { + const shares: Record> = {}; + participants.forEach(p => { + shares[p.id] = initSplitShares(); + shares[p.id]![SplitType.EQUAL] = 1n; + }); + return shares; + }; + + it('should distribute leftover penny to different participants for different transactions', () => { + const splitShares = getEqualSplitShares(participants); + + // Test Case 1: Amount that doesn't divide evenly by 3 + const result1 = calculateParticipantSplit( + 100n, // $1.00 + participants, + SplitType.EQUAL, + splitShares, + participants[0], // Alice is paying + 'transaction-1' + ); + + // Test Case 2: Different amount + const result2 = calculateParticipantSplit( + 200n, // $2.00 + participants, + SplitType.EQUAL, + splitShares, + participants[0], + 'transaction-2' + ); + + // Test Case 3: Another different amount + const result3 = calculateParticipantSplit( + 400n, // $4.00 + participants, + SplitType.EQUAL, + splitShares, + participants[0], + 'transaction-3' + ); + + // Get the person who got the leftover penny in each case + const getPennyRecipient = (participants: Participant[]): number | undefined => { + const payer = participants[0]; + if (!payer?.amount) return undefined; + + const nonPayerShare = participants + .find(p => p.id !== payer.id && p.amount) + ?.amount; + + return participants.find(p => + p.id !== payer.id && p.amount && p.amount !== nonPayerShare + )?.id; + }; + + const recipient1 = getPennyRecipient(result1.participants); + const recipient2 = getPennyRecipient(result2.participants); + const recipient3 = getPennyRecipient(result3.participants); + + // Verify that we got different recipients + expect(new Set([recipient1, recipient2, recipient3]).size).toBeGreaterThan(1); + }); + + it('should maintain consistent penny distribution for same transaction with different descriptions', () => { + const splitShares = getEqualSplitShares(participants); + const amount = 100n; // $1.00 + const transactionId = 'consistent-transaction'; + + // Original calculation + const originalResult = calculateParticipantSplit( + amount, + participants, + SplitType.EQUAL, + splitShares, + participants[0], + transactionId + ); + + // Same transaction, different metadata + const modifications = [ + { description: 'Changed title' }, + { description: 'Added emoji 🍕' }, + { description: 'Changed category' } + ]; + + // Test that all modifications result in the same distribution + modifications.forEach(mod => { + const modifiedResult = calculateParticipantSplit( + amount, + participants, + SplitType.EQUAL, + splitShares, + participants[0], + transactionId + ); + + // Compare amounts for all participants + originalResult.participants.forEach((originalParticipant, index) => { + const modifiedAmount = modifiedResult.participants[index]?.amount; + expect(modifiedAmount).toBe(originalParticipant.amount); + }); + }); + }); + + it('should handle different transaction IDs with same amount', () => { + const splitShares = getEqualSplitShares(participants); + const amount = 1001n; // $10.01 -amount that doesn't divide evenly + + // Helper to identify who got extra pennies + const getNonPayerAmounts = (result: { participants: Participant[] }): Map => { + const payer = participants[0]; + if (!payer) return new Map(); + + return new Map( + result.participants + .filter(p => p.id !== payer.id) + .map(p => [p.id, p.amount ?? 0n]) + ); + }; + + // Test with 10 different transaction IDs + const results = Array.from({ length: 10 }, (_, i) => + calculateParticipantSplit( + amount, + participants, + SplitType.EQUAL, + splitShares, + participants[0], + `transaction-${i + 1}` + ) + ); + + // Get the distribution for each transaction + const distributions = results.map(getNonPayerAmounts); + + // Compare each distribution with others to ensure we have differences + let hasDistinctDistributions = false; + for (let i = 0; i < distributions.length - 1; i++) { + const dist1 = distributions[i]; + const dist2 = distributions[i + 1]; + + if (dist1 && dist2) { + // Check if the distributions are different + const isDifferent = Array.from(dist1.entries()).some( + ([id, amount]) => dist2.get(id) !== amount + ); + + if (isDifferent) { + hasDistinctDistributions = true; + break; + } + } + } + + expect(hasDistinctDistributions).toBe(true); + + // Double-check that all distributions sum to zero + results.forEach(result => { + const total = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); + expect(total).toBe(0n); + + // Log the distribution for debugging + console.log('Distribution:', + result.participants.map(p => ({ + id: p.id, + amount: p.amount?.toString() + })) + ); + }); + }); +}); + describe('calculateSplitShareBasedOnAmount', () => { describe('SplitType.EQUAL', () => { it('should set equal shares for participants with non-zero amounts', () => { @@ -475,6 +641,20 @@ describe('calculateSplitShareBasedOnAmount', () => { expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); // Zero amount }); + + it('should set zero shares for participants with zero amounts', () => { + const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); + + expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n); + expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n); + expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); + }); }); describe('SplitType.PERCENTAGE', () => { @@ -670,33 +850,6 @@ describe('calculateSplitShareBasedOnAmount', () => { expect(Object.keys(splitShares)).toHaveLength(0); }); - it('should handle when one participant owes entire amount', () => { - const participants = createParticipants([user1, user2], [10000n, -10000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); - }); - - it('should handle self-payment (no money flow) scenario', () => { - const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); - }); - it('should handle undefined paidBy', () => { const participants = createParticipants([user1, user2], [-5000n, -5000n]); const splitShares: Record> = {}; diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 429cd5bc..80f361c0 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -5,7 +5,6 @@ import { create } from 'zustand'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { type CurrencyCode } from '~/lib/currency'; import type { TransactionAddInputModel } from '~/types'; -import { shuffleArray } from '~/utils/array'; import { BigMath } from '~/utils/numbers'; export type Participant = User & { amount?: bigint }; @@ -104,6 +103,7 @@ export const useAddExpenseStore = create()((set) => ({ s.splitType, s.splitShares, s.paidBy, + s.transactionId ), }; }), @@ -117,6 +117,7 @@ export const useAddExpenseStore = create()((set) => ({ splitType, state.splitShares, state.paidBy, + state.transactionId ), })), setSplitShare: (splitType, userId, share) => @@ -162,6 +163,7 @@ export const useAddExpenseStore = create()((set) => ({ state.splitType, splitShares, state.paidBy, + state.transactionId ), }; }), @@ -191,6 +193,7 @@ export const useAddExpenseStore = create()((set) => ({ splitType, splitShares, state.paidBy, + state.transactionId ), }; }), @@ -248,7 +251,7 @@ export const useAddExpenseStore = create()((set) => ({ setPaidBy: (paidBy) => set((s) => ({ paidBy, - ...calculateParticipantSplit(s.amount, s.participants, s.splitType, s.splitShares, paidBy), + ...calculateParticipantSplit(s.amount, s.participants, s.splitType, s.splitShares, paidBy, s.description), })), setCurrentUser: (currentUser) => set((s) => { @@ -305,6 +308,7 @@ export function calculateParticipantSplit( splitType: SplitType, splitShares: SplitShares, paidBy?: Participant, + transactionId: string = '' ) { let canSplitScreenClosed = true; if (0n === amount) { @@ -374,10 +378,45 @@ export function calculateParticipantSplit( const participantsToPick = updatedParticipants.filter((p) => p.amount); if (0 < participantsToPick.length) { - shuffleArray(participantsToPick); + + const hash = (value: number, inputs: { amount: bigint; transactionId: string }): number => { + let hash = 5381; + + // Hash the transactionId first to give it more weight + for (let i = 0; i < inputs.transactionId.length; i++) { + const char = inputs.transactionId.charCodeAt(i); + hash = ((hash << 5) + hash) + char; + hash = hash & hash; + } + + // Multiply by a large prime to amplify the transaction ID's influence + hash *= 31; + + // Then add amount's influence + const amountStr = inputs.amount.toString(); + const lastDigits = amountStr.slice(-6); + for (let i = 0; i < lastDigits.length; i++) { + const char = lastDigits.charCodeAt(i); + hash = ((hash << 5) + hash) + char; + hash = hash & hash; + } + + // Finally add participant's influence + hash = ((hash << 5) + hash) + value; + hash = hash & hash; + + return Math.abs(hash); + }; + + const shuffled = [...participantsToPick].sort((a, b) => { + const hashA = hash(a.id, { amount, transactionId }); + const hashB = hash(b.id, { amount, transactionId }); + return hashA - hashB; + }); + let i = 0; while (0n !== penniesLeft) { - const p = participantsToPick[i % participantsToPick.length]!; + const p = shuffled[i % shuffled.length]!; p.amount! -= BigMath.sign(penniesLeft); penniesLeft -= BigMath.sign(penniesLeft); i++; @@ -397,12 +436,12 @@ export function calculateSplitShareBasedOnAmount( splitType: SplitType, splitShares: SplitShares, paidBy?: User, + description: string = '', ) { switch (splitType) { case SplitType.EQUAL: participants.forEach((p) => { - splitShares[p.id]![splitType] = - (p.id === paidBy?.id ? amount : 0n) === p.amount && participants.length > 1 ? 0n : 1n; + splitShares[p.id]![splitType] = 0n === p.amount && participants.length > 1 ? 0n : 1n; }); break; From 8819d5e9d7f03eb208ade87c6b1b4c6c43d88fac Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 14 Oct 2025 13:10:10 +0200 Subject: [PATCH 2/5] fix issues --- src/store/addStore.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 80f361c0..596b6128 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -251,7 +251,7 @@ export const useAddExpenseStore = create()((set) => ({ setPaidBy: (paidBy) => set((s) => ({ paidBy, - ...calculateParticipantSplit(s.amount, s.participants, s.splitType, s.splitShares, paidBy, s.description), + ...calculateParticipantSplit(s.amount, s.participants, s.splitType, s.splitShares, paidBy), })), setCurrentUser: (currentUser) => set((s) => { @@ -435,8 +435,7 @@ export function calculateSplitShareBasedOnAmount( participants: Participant[], splitType: SplitType, splitShares: SplitShares, - paidBy?: User, - description: string = '', + paidBy?: User ) { switch (splitType) { case SplitType.EQUAL: From f99718ad44d620f1e7f1abf9a0ba62bc191e82a2 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 14 Oct 2025 13:48:02 +0200 Subject: [PATCH 3/5] syncing new addStore.test.ts'tests added after my fork --- src/store/addStore.test.ts | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/store/addStore.test.ts b/src/store/addStore.test.ts index 75f55703..d83771cc 100644 --- a/src/store/addStore.test.ts +++ b/src/store/addStore.test.ts @@ -128,8 +128,26 @@ describe('calculateParticipantSplit', () => { expect(totalOwed).toBe(0n); // Total should always balance // Payer should get the remainder expect(result.participants[0]?.amount).toBeGreaterThanOrEqual(6667n); }); + + it('should handle single participant as payer', () => { + const participants = createParticipants([user1]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(0n); // Payer pays and owes nothing + expect(result.canSplitScreenClosed).toBe(true); + }); }); + + describe('SplitType.PERCENTAGE', () => { it('should split amount by percentage', () => { const participants = createParticipants([user1, user2, user3]); @@ -850,6 +868,33 @@ describe('calculateSplitShareBasedOnAmount', () => { expect(Object.keys(splitShares)).toHaveLength(0); }); + it('should handle when one participant owes entire amount', () => { + const participants = createParticipants([user1, user2], [10000n, -10000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); + + expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n); + expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); + }); + + it('should handle self-payment (no money flow) scenario', () => { + const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); + + expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); + expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n); + expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); + }); + it('should handle undefined paidBy', () => { const participants = createParticipants([user1, user2], [-5000n, -5000n]); const splitShares: Record> = {}; From 28b4209123081cb1d840c2fccf077bf396ea569f Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 4 Nov 2025 22:58:58 +0100 Subject: [PATCH 4/5] Fixed left-over penny issue by preserving penny distribution when editing expenses --- src/store/addStore.test.ts | 2254 +++++++++++++++++------------------- src/store/addStore.ts | 104 +- 2 files changed, 1116 insertions(+), 1242 deletions(-) diff --git a/src/store/addStore.test.ts b/src/store/addStore.test.ts index d83771cc..8a3eb519 100644 --- a/src/store/addStore.test.ts +++ b/src/store/addStore.test.ts @@ -1,1185 +1,1069 @@ -import { SplitType, type User } from '@prisma/client'; - -import { - type Participant, - calculateParticipantSplit, - calculateSplitShareBasedOnAmount, - initSplitShares, -} from './addStore'; - -// Mock dependencies -jest.mock('~/utils/array', () => ({ - shuffleArray: jest.fn((arr: T[]): T[] => arr), // No shuffling for predictable tests -})); - -// Create mock users for testing -const createMockUser = (id: number, name: string, email: string): User => ({ - id, - name, - email, - currency: 'USD', - emailVerified: null, - image: null, - preferredLanguage: 'en', - obapiProviderId: null, - bankingId: null, -}); - -const user1: User = createMockUser(1, 'Alice', 'alice@example.com'); -const user2: User = createMockUser(2, 'Bob', 'bob@example.com'); -const user3: User = createMockUser(3, 'Charlie', 'charlie@example.com'); - -// Create participants with initial amounts -const createParticipants = (users: User[], amounts: bigint[] = []): Participant[] => - users.map((user, index) => ({ - ...user, - amount: amounts[index] ?? 0n, - })); - -// Helper to create split shares structure -const createSplitShares = (participants: Participant[], splitType: SplitType, shares: bigint[]) => { - const splitShares: Record> = {}; - - participants.forEach((participant, index) => { - splitShares[participant.id] = initSplitShares(); - splitShares[participant.id]![splitType] = shares[index] ?? 0n; - }); - - return splitShares; -}; - -describe('calculateParticipantSplit', () => { - describe('Edge cases', () => { - it('should return participants unchanged when amount is 0', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const result = calculateParticipantSplit( - 0n, - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - expect(result.participants).toEqual(participants); - expect(result.canSplitScreenClosed).toBe(true); - }); - it('should handle empty participants array', () => { - const result = calculateParticipantSplit(10000n, [], SplitType.EQUAL, {}, undefined); - - expect(result.participants).toEqual([]); - expect(result.canSplitScreenClosed).toBe(false); - }); - }); - - describe('SplitType.EQUAL', () => { - it('should split amount equally among all participants', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const result = calculateParticipantSplit( - 30000n, // $300.00 - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Each person should owe $100 (10000n), but payer gets the difference - expect(result.participants[0]?.amount).toBe(20000n); // Payer: -10000n + 30000n = 20000n - expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 - expect(result.participants[2]?.amount).toBe(-10000n); // Owes $100 - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should exclude participants with 0 share from equal split', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 0n]); - - const result = calculateParticipantSplit( - 20000n, // $200.00 - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Only first two participants split the cost - expect(result.participants[0]?.amount).toBe(10000n); // Payer: -10000n + 20000n = 10000n - expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 - expect(result.participants[2]?.amount).toBe(0n); // Excluded from split - }); - - it('should handle uneven division with penny adjustment', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const result = calculateParticipantSplit( - 10001n, // $100.01 (not evenly divisible by 3) - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Should distribute the extra penny - const totalOwed = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - expect(totalOwed).toBe(0n); // Total should always balance // Payer should get the remainder - expect(result.participants[0]?.amount).toBeGreaterThanOrEqual(6667n); - }); - - it('should handle single participant as payer', () => { - const participants = createParticipants([user1]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(0n); // Payer pays and owes nothing - expect(result.canSplitScreenClosed).toBe(true); - }); - }); - - - - describe('SplitType.PERCENTAGE', () => { - it('should split amount by percentage', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 5000n, // 50% - 3000n, // 30% - 2000n, // 20% - ]); - - const result = calculateParticipantSplit( - 10000n, // $100.00 - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(5000n); // Payer: -5000n + 10000n = 5000n - expect(result.participants[1]?.amount).toBe(-3000n); // Owes 30% - expect(result.participants[2]?.amount).toBe(-2000n); // Owes 20% - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should mark incomplete when percentages do not sum to 100%', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 4000n, // 40% - 3000n, // 30% - 2000n, // 20% (total = 90%) - ]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - expect(result.canSplitScreenClosed).toBe(false); - }); - - it('should handle 0% share participants', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 7000n, // 70% - 3000n, // 30% - 0n, // 0% - ]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(3000n); // Payer: -7000n + 10000n - expect(result.participants[1]?.amount).toBe(-3000n); - expect(result.participants[2]?.amount).toBe(0n); // No share - }); - }); - - describe('SplitType.SHARE', () => { - it('should split amount by shares', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.SHARE, [ - 200n, // 2 shares - 100n, // 1 share - 100n, // 1 share (total = 4 shares) - ]); - - const result = calculateParticipantSplit( - 12000n, // $120.00 - participants, - SplitType.SHARE, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(6000n); // Payer: -6000n + 12000n = 6000n (50%) - expect(result.participants[1]?.amount).toBe(-3000n); // 25% - expect(result.participants[2]?.amount).toBe(-3000n); // 25% - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should handle participants with 0 shares', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.SHARE, [ - 300n, // 3 shares - 100n, // 1 share - 0n, // 0 shares - ]); - - const result = calculateParticipantSplit( - 8000n, - participants, - SplitType.SHARE, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(2000n); // Payer: -6000n + 8000n (75%) - expect(result.participants[1]?.amount).toBe(-2000n); // 25% - expect(result.participants[2]?.amount).toBe(0n); // No shares - }); - - it('should mark incomplete when no shares are assigned', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.SHARE, [0n, 0n, 0n]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.SHARE, - splitShares, - user1, - ); - - expect(result.canSplitScreenClosed).toBe(false); - }); - }); - - describe('SplitType.EXACT', () => { - it('should assign exact amounts to participants', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EXACT, [ - 4000n, // $40.00 - 3000n, // $30.00 - 3000n, // $30.00 - ]); - - const result = calculateParticipantSplit( - 10000n, // $100.00 - participants, - SplitType.EXACT, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(6000n); // Payer: -4000n + 10000n - expect(result.participants[1]?.amount).toBe(-3000n); - expect(result.participants[2]?.amount).toBe(-3000n); - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should mark incomplete when exact amounts do not sum to total', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EXACT, [ - 4000n, // $40.00 - 3000n, // $30.00 - 2000n, // $20.00 (total = $90.00) - ]); - - const result = calculateParticipantSplit( - 10000n, // $100.00 - participants, - SplitType.EXACT, - splitShares, - user1, - ); - - expect(result.canSplitScreenClosed).toBe(false); - }); - - it('should handle undefined exact amounts as 0', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EXACT, [ - 10000n, // $100.00 - 0n, // $0.00 - 0n, // $0.00 - ]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.EXACT, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(0n); // Payer: -10000n + 10000n - expect(result.participants[1]?.amount).toBe(0n); - expect(result.participants[2]?.amount).toBe(0n); - }); - }); - - describe('SplitType.ADJUSTMENT', () => { - it('should distribute remaining amount equally after adjustments', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ - 1000n, // +$10.00 adjustment - -500n, // -$5.00 adjustment - 0n, // No adjustment - ]); - - const result = calculateParticipantSplit( - 15000n, // $150.00 - participants, - SplitType.ADJUSTMENT, - splitShares, - user1, - ); - - // Remaining after adjustments: 15000n - 1000n - (-500n) - 0n = 14500n - // Split equally: 14500n / 3 = ~4833n each - const baseShare = (15000n - 1000n - -500n - 0n) / 3n; // 4833n - - expect(result.participants[0]?.amount).toBe(9166n); // Payer adjustment (adjusted for rounding) - expect(result.participants[1]?.amount).toBe(-(baseShare - 500n)); - expect(result.participants[2]?.amount).toBe(-baseShare); - }); - - it('should mark incomplete when adjustments exceed total amount', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ - 8000n, // Large positive adjustment - 5000n, // Another large adjustment - 0n, - ]); - - const result = calculateParticipantSplit( - 10000n, // Total amount smaller than adjustments - participants, - SplitType.ADJUSTMENT, - splitShares, - user1, - ); - - expect(result.canSplitScreenClosed).toBe(false); - }); - }); - - describe('Payer scenarios', () => { - it('should handle different payers correctly', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - // Test with user2 as payer - const result = calculateParticipantSplit( - 30000n, - participants, - SplitType.EQUAL, - splitShares, - user2, - ); - - expect(result.participants[0]?.amount).toBe(-10000n); // Owes - expect(result.participants[1]?.amount).toBe(20000n); // Payer: -10000n + 30000n - expect(result.participants[2]?.amount).toBe(-10000n); // Owes - }); - - it('should handle when payer is not in participants list', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const externalPayer = createMockUser(4, 'David', 'david@example.com'); - - const result = calculateParticipantSplit( - 30000n, - participants, - SplitType.EQUAL, - splitShares, - externalPayer, - ); // All participants should owe their share but total balances to 0 due to penny adjustment - expect(result.participants[0]?.amount).toBe(0n); - expect(result.participants[1]?.amount).toBe(0n); - expect(result.participants[2]?.amount).toBe(0n); - }); - }); - - describe('Balance verification', () => { - it('should always maintain balance (total amounts sum to 0)', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 4000n, // 40% - 3500n, // 35% - 2500n, // 25% - ]); - - const result = calculateParticipantSplit( - 12345n, // Odd amount - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - - expect(totalAmount).toBe(0n); - }); - - it('should handle penny adjustments correctly', () => { - const participants = createParticipants([user1, user2]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n]); - - const result = calculateParticipantSplit( - 1n, // $0.01 - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // One participant should get the penny - const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - - expect(totalAmount).toBe(0n); - }); - }); -}); - -describe('calculateParticipantSplit leftover penny distribution', () => { - // Mock participants that we'll use across tests - const participants: Participant[] = [ - createMockUser(1, 'Alice', 'alice@test.com'), - createMockUser(2, 'Bob', 'bob@test.com'), - createMockUser(3, 'Charlie', 'charlie@test.com') - ]; - - // Helper to get split shares for equal split - const getEqualSplitShares = (participants: Participant[]): Record> => { - const shares: Record> = {}; - participants.forEach(p => { - shares[p.id] = initSplitShares(); - shares[p.id]![SplitType.EQUAL] = 1n; - }); - return shares; - }; - - it('should distribute leftover penny to different participants for different transactions', () => { - const splitShares = getEqualSplitShares(participants); - - // Test Case 1: Amount that doesn't divide evenly by 3 - const result1 = calculateParticipantSplit( - 100n, // $1.00 - participants, - SplitType.EQUAL, - splitShares, - participants[0], // Alice is paying - 'transaction-1' - ); - - // Test Case 2: Different amount - const result2 = calculateParticipantSplit( - 200n, // $2.00 - participants, - SplitType.EQUAL, - splitShares, - participants[0], - 'transaction-2' - ); - - // Test Case 3: Another different amount - const result3 = calculateParticipantSplit( - 400n, // $4.00 - participants, - SplitType.EQUAL, - splitShares, - participants[0], - 'transaction-3' - ); - - // Get the person who got the leftover penny in each case - const getPennyRecipient = (participants: Participant[]): number | undefined => { - const payer = participants[0]; - if (!payer?.amount) return undefined; - - const nonPayerShare = participants - .find(p => p.id !== payer.id && p.amount) - ?.amount; - - return participants.find(p => - p.id !== payer.id && p.amount && p.amount !== nonPayerShare - )?.id; - }; - - const recipient1 = getPennyRecipient(result1.participants); - const recipient2 = getPennyRecipient(result2.participants); - const recipient3 = getPennyRecipient(result3.participants); - - // Verify that we got different recipients - expect(new Set([recipient1, recipient2, recipient3]).size).toBeGreaterThan(1); - }); - - it('should maintain consistent penny distribution for same transaction with different descriptions', () => { - const splitShares = getEqualSplitShares(participants); - const amount = 100n; // $1.00 - const transactionId = 'consistent-transaction'; - - // Original calculation - const originalResult = calculateParticipantSplit( - amount, - participants, - SplitType.EQUAL, - splitShares, - participants[0], - transactionId - ); - - // Same transaction, different metadata - const modifications = [ - { description: 'Changed title' }, - { description: 'Added emoji 🍕' }, - { description: 'Changed category' } - ]; - - // Test that all modifications result in the same distribution - modifications.forEach(mod => { - const modifiedResult = calculateParticipantSplit( - amount, - participants, - SplitType.EQUAL, - splitShares, - participants[0], - transactionId - ); - - // Compare amounts for all participants - originalResult.participants.forEach((originalParticipant, index) => { - const modifiedAmount = modifiedResult.participants[index]?.amount; - expect(modifiedAmount).toBe(originalParticipant.amount); - }); - }); - }); - - it('should handle different transaction IDs with same amount', () => { - const splitShares = getEqualSplitShares(participants); - const amount = 1001n; // $10.01 -amount that doesn't divide evenly - - // Helper to identify who got extra pennies - const getNonPayerAmounts = (result: { participants: Participant[] }): Map => { - const payer = participants[0]; - if (!payer) return new Map(); - - return new Map( - result.participants - .filter(p => p.id !== payer.id) - .map(p => [p.id, p.amount ?? 0n]) - ); - }; - - // Test with 10 different transaction IDs - const results = Array.from({ length: 10 }, (_, i) => - calculateParticipantSplit( - amount, - participants, - SplitType.EQUAL, - splitShares, - participants[0], - `transaction-${i + 1}` - ) - ); - - // Get the distribution for each transaction - const distributions = results.map(getNonPayerAmounts); - - // Compare each distribution with others to ensure we have differences - let hasDistinctDistributions = false; - for (let i = 0; i < distributions.length - 1; i++) { - const dist1 = distributions[i]; - const dist2 = distributions[i + 1]; - - if (dist1 && dist2) { - // Check if the distributions are different - const isDifferent = Array.from(dist1.entries()).some( - ([id, amount]) => dist2.get(id) !== amount - ); - - if (isDifferent) { - hasDistinctDistributions = true; - break; - } - } - } - - expect(hasDistinctDistributions).toBe(true); - - // Double-check that all distributions sum to zero - results.forEach(result => { - const total = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - expect(total).toBe(0n); - - // Log the distribution for debugging - console.log('Distribution:', - result.participants.map(p => ({ - id: p.id, - amount: p.amount?.toString() - })) - ); - }); - }); -}); - -describe('calculateSplitShareBasedOnAmount', () => { - describe('SplitType.EQUAL', () => { - it('should set equal shares for participants with non-zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [5000n, 5000n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); // Zero amount - }); - - it('should set zero shares for participants with zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); - }); - }); - - describe('SplitType.PERCENTAGE', () => { - it('should calculate percentage shares for regular participants', () => { - const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.PERCENTAGE, splitShares); - - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% - expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% - }); - - it('should calculate percentage shares when payer is specified', () => { - const participants = createParticipants([user1, user2, user3], [8000n, -3000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - // user1 is payer: paid 10000n but their amount is 8000n, so they owe 2000n (20%) - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% - expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% - }); - - it('should handle zero amount', () => { - const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(0n, participants, SplitType.PERCENTAGE, splitShares); - - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(0n); - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(0n); - expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(0n); - }); - }); - - describe('SplitType.SHARE', () => { - it('should calculate share values based on participant amounts', () => { - const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares); - - // Shares should be proportional: 6000:3000:3000 = 2:1:1 - // Multiplied by 100 and normalized - expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); // 2 shares * 100 - expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 - expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 - }); - - it('should handle payer in share calculation', () => { - const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares, user1); - - // user1 is payer: paid 12000n but their amount is 6000n, so they owe 6000n - // Shares: 6000:3000:3000 = 2:1:1 - expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); - expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); - expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); - }); - - it('should handle zero amount', () => { - const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(0n, participants, SplitType.SHARE, splitShares); - - expect(splitShares[user1.id]![SplitType.SHARE]).toBe(0n); - expect(splitShares[user2.id]![SplitType.SHARE]).toBe(0n); - expect(splitShares[user3.id]![SplitType.SHARE]).toBe(0n); - }); - }); - - describe('SplitType.EXACT', () => { - it('should set exact amounts for regular participants', () => { - const participants = createParticipants([user1, user2, user3], [-4000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); - - expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); - expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); - expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); - }); - - it('should handle payer in exact calculation', () => { - const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares, user1); - - // user1 is payer: paid 10000n but their amount is 6000n, so they owe 4000n - expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); - expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); - expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); - }); - - it('should handle zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); - - expect(splitShares[user1.id]![SplitType.EXACT]).toBe(0n); - expect(splitShares[user2.id]![SplitType.EXACT]).toBe(0n); - expect(splitShares[user3.id]![SplitType.EXACT]).toBe(0n); - }); - }); - - describe('SplitType.ADJUSTMENT', () => { - it('should handle payer in adjustment calculation', () => { - const participants = createParticipants([user1, user2, user3], [-9500n, -4500n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 15000n, - participants, - SplitType.ADJUSTMENT, - splitShares, - user1, - ); - - expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(1000n); - expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); - expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(500n); - }); - - it('should handle participants with zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [0n, -5000n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(5000n, participants, SplitType.ADJUSTMENT, splitShares); - - // Only user2 has non-zero amount, so equal share = 5000n / 1 = 5000n - // user2 owes 5000n vs 5000n = 0n adjustment - expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n - expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); // 5000n - 5000n - expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n - }); - }); - - describe('Edge cases', () => { - it('should handle empty participants array', () => { - const participants: Participant[] = []; - const splitShares: Record> = {}; - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares); - - // Should not throw an error and splitShares should remain empty - expect(Object.keys(splitShares)).toHaveLength(0); - }); - - it('should handle when one participant owes entire amount', () => { - const participants = createParticipants([user1, user2], [10000n, -10000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); - }); - - it('should handle self-payment (no money flow) scenario', () => { - const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); - }); - - it('should handle undefined paidBy', () => { - const participants = createParticipants([user1, user2], [-5000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - undefined, - ); - - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(5000n); - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(5000n); - }); - - it('should handle all split types with same data', () => { - const participants = createParticipants([user1, user2], [-6000n, -4000n]); - - Object.values(SplitType).forEach((splitType) => { - if (splitType === SplitType.SETTLEMENT) { - return; - } // Skip settlement as it's not implemented - - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - // Should not throw for any split type - expect(() => { - calculateSplitShareBasedOnAmount(10000n, participants, splitType, splitShares); - }).not.toThrow(); - }); - }); - }); -}); - -// Integration tests for function reversibility -describe('Function Reversibility Tests', () => { - describe('calculateParticipantSplit -> calculateSplitShareBasedOnAmount', () => { - it('should properly reverse EQUAL split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1n, 1n, 1n]; - const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 15000n, - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 15000n, - splitResult.participants, - SplitType.EQUAL, - reversedShares, - user1, - ); - - // Check if we got back the original shares - expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); - }); - - it('should properly reverse PERCENTAGE split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [5000n, 3000n, 2000n]; // 50%, 30%, 20% - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 20000n, - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 20000n, - splitResult.participants, - SplitType.PERCENTAGE, - reversedShares, - user1, - ); - - // Check if we got back the original percentage shares - expect(reversedShares[user1.id]![SplitType.PERCENTAGE]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.PERCENTAGE]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.PERCENTAGE]).toBe(originalShares[2]); - }); - - it('should properly reverse SHARE split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [400n, 200n, 200n]; // 2:1:1 ratio - const splitShares = createSplitShares(participants, SplitType.SHARE, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 16000n, - participants, - SplitType.SHARE, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 16000n, - splitResult.participants, - SplitType.SHARE, - reversedShares, - user1, - ); - - // Check if we got back proportional shares (should maintain the 2:1:1 ratio) - const ratio1 = Number(reversedShares[user1.id]![SplitType.SHARE]) / Number(originalShares[0]); - const ratio2 = Number(reversedShares[user2.id]![SplitType.SHARE]) / Number(originalShares[1]); - const ratio3 = Number(reversedShares[user3.id]![SplitType.SHARE]) / Number(originalShares[2]); - - // All ratios should be approximately equal (accounting for rounding) - expect(Math.abs(ratio1 - ratio2)).toBeLessThan(0.1); - expect(Math.abs(ratio2 - ratio3)).toBeLessThan(0.1); - }); - - it('should properly reverse EXACT split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [6000n, 4000n, 2000n]; - const splitShares = createSplitShares(participants, SplitType.EXACT, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 12000n, - participants, - SplitType.EXACT, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 12000n, - splitResult.participants, - SplitType.EXACT, - reversedShares, - user1, - ); - - // Check if we got back the original exact amounts - expect(reversedShares[user1.id]![SplitType.EXACT]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.EXACT]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.EXACT]).toBe(originalShares[2]); - }); - - it('should handle ADJUSTMENT split reversal (known to have bugs)', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1000n, 0n, 500n]; // Various adjustments - const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 12300n, - participants, - SplitType.ADJUSTMENT, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 12300n, - splitResult.participants, - SplitType.ADJUSTMENT, - reversedShares, - user1, - ); - - expect(reversedShares[user1.id]![SplitType.ADJUSTMENT]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.ADJUSTMENT]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.ADJUSTMENT]).toBe(originalShares[2]); - }); - - it('should handle edge case with zero amounts', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1n, 1n, 0n]; // One participant excluded - const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 10000n, - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 10000n, - splitResult.participants, - SplitType.EQUAL, - reversedShares, - user1, - ); - - // Check if we preserved the zero share - expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); - }); - - it('should handle external payer scenario', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1n, 1n, 1n]; - const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); - const externalPayer = createMockUser(4, 'External', 'external@example.com'); - - // Apply split calculation with external payer - const splitResult = calculateParticipantSplit( - 15000n, - participants, - SplitType.EQUAL, - splitShares, - externalPayer, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 15000n, - splitResult.participants, - SplitType.EQUAL, - reversedShares, - externalPayer, - ); - - // With external payer, all participants have 0 amounts due to balance adjustment - // So reversed shares should all be 0 - expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(0n); - expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(0n); - expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(0n); - }); - }); -}); +import { SplitType, type User } from '@prisma/client'; + +import { + type Participant, + calculateParticipantSplit, + calculateSplitShareBasedOnAmount, + initSplitShares, + useAddExpenseStore, +} from '~/store/addStore'; + +// Mock dependencies +jest.mock('~/utils/array', () => ({ + shuffleArray: jest.fn((arr: T[]): T[] => arr), // No shuffling for predictable tests +})); + +// Create mock users for testing +const createMockUser = (id: number, name: string, email: string): User => ({ + id, + name, + email, + currency: 'USD', + emailVerified: null, + image: null, + preferredLanguage: 'en', + obapiProviderId: null, + bankingId: null, +}); + +const user1: User = createMockUser(1, 'Alice', 'alice@example.com'); +const user2: User = createMockUser(2, 'Bob', 'bob@example.com'); +const user3: User = createMockUser(3, 'Charlie', 'charlie@example.com'); + +// Create participants with initial amounts +const createParticipants = (users: User[], amounts: bigint[] = []): Participant[] => + users.map((user, index) => ({ + ...user, + amount: amounts[index] ?? 0n, + })); + +// Helper to create split shares structure +const createSplitShares = (participants: Participant[], splitType: SplitType, shares: bigint[]) => { + const splitShares: Record> = {}; + + participants.forEach((participant, index) => { + splitShares[participant.id] = initSplitShares(); + splitShares[participant.id]![splitType] = shares[index] ?? 0n; + }); + + return splitShares; +}; + +describe('calculateParticipantSplit', () => { + describe('Edge cases', () => { + it('should return participants unchanged when amount is 0', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + const result = calculateParticipantSplit( + 0n, + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + expect(result.participants).toEqual(participants); + expect(result.canSplitScreenClosed).toBe(true); + }); + it('should handle empty participants array', () => { + const result = calculateParticipantSplit(10000n, [], SplitType.EQUAL, {}, undefined); + + expect(result.participants).toEqual([]); + expect(result.canSplitScreenClosed).toBe(false); + }); + }); + + describe('SplitType.EQUAL', () => { + it('should split amount equally among all participants', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + const result = calculateParticipantSplit( + 30000n, // $300.00 + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Each person should owe $100 (10000n), but payer gets the difference + expect(result.participants[0]?.amount).toBe(20000n); // Payer: -10000n + 30000n = 20000n + expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 + expect(result.participants[2]?.amount).toBe(-10000n); // Owes $100 + expect(result.canSplitScreenClosed).toBe(true); + }); + + it('should exclude participants with 0 share from equal split', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 0n]); + + const result = calculateParticipantSplit( + 20000n, // $200.00 + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Only first two participants split the cost + expect(result.participants[0]?.amount).toBe(10000n); // Payer: -10000n + 20000n = 10000n + expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 + expect(result.participants[2]?.amount).toBe(0n); // Excluded from split + }); + + it('should handle uneven division with penny adjustment', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + const result = calculateParticipantSplit( + 10001n, // $100.01 (not evenly divisible by 3) + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Should distribute the extra penny + const totalOwed = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); + expect(totalOwed).toBe(0n); // Total should always balance // Payer should get the remainder + expect(result.participants[0]?.amount).toBeGreaterThanOrEqual(6667n); + }); + + it('should handle single participant as payer', () => { + const participants = createParticipants([user1]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(0n); // Payer pays and owes nothing + expect(result.canSplitScreenClosed).toBe(true); + }); + }); + + describe('SplitType.PERCENTAGE', () => { + it('should split amount by percentage', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ + 5000n, // 50% + 3000n, // 30% + 2000n, // 20% + ]); + + const result = calculateParticipantSplit( + 10000n, // $100.00 + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(5000n); // Payer: -5000n + 10000n = 5000n + expect(result.participants[1]?.amount).toBe(-3000n); // Owes 30% + expect(result.participants[2]?.amount).toBe(-2000n); // Owes 20% + expect(result.canSplitScreenClosed).toBe(true); + }); + + it('should mark incomplete when percentages do not sum to 100%', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ + 4000n, // 40% + 3000n, // 30% + 2000n, // 20% (total = 90%) + ]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + expect(result.canSplitScreenClosed).toBe(false); + }); + + it('should handle 0% share participants', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ + 7000n, // 70% + 3000n, // 30% + 0n, // 0% + ]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(3000n); // Payer: -7000n + 10000n + expect(result.participants[1]?.amount).toBe(-3000n); + expect(result.participants[2]?.amount).toBe(0n); // No share + }); + }); + + describe('SplitType.SHARE', () => { + it('should split amount by shares', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.SHARE, [ + 200n, // 2 shares + 100n, // 1 share + 100n, // 1 share (total = 4 shares) + ]); + + const result = calculateParticipantSplit( + 12000n, // $120.00 + participants, + SplitType.SHARE, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(6000n); // Payer: -6000n + 12000n = 6000n (50%) + expect(result.participants[1]?.amount).toBe(-3000n); // 25% + expect(result.participants[2]?.amount).toBe(-3000n); // 25% + expect(result.canSplitScreenClosed).toBe(true); + }); + + it('should handle participants with 0 shares', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.SHARE, [ + 300n, // 3 shares + 100n, // 1 share + 0n, // 0 shares + ]); + + const result = calculateParticipantSplit( + 8000n, + participants, + SplitType.SHARE, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(2000n); // Payer: -6000n + 8000n (75%) + expect(result.participants[1]?.amount).toBe(-2000n); // 25% + expect(result.participants[2]?.amount).toBe(0n); // No shares + }); + + it('should mark incomplete when no shares are assigned', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.SHARE, [0n, 0n, 0n]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.SHARE, + splitShares, + user1, + ); + + expect(result.canSplitScreenClosed).toBe(false); + }); + }); + + describe('SplitType.EXACT', () => { + it('should assign exact amounts to participants', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EXACT, [ + 4000n, // $40.00 + 3000n, // $30.00 + 3000n, // $30.00 + ]); + + const result = calculateParticipantSplit( + 10000n, // $100.00 + participants, + SplitType.EXACT, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(6000n); // Payer: -4000n + 10000n + expect(result.participants[1]?.amount).toBe(-3000n); + expect(result.participants[2]?.amount).toBe(-3000n); + expect(result.canSplitScreenClosed).toBe(true); + }); + + it('should mark incomplete when exact amounts do not sum to total', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EXACT, [ + 4000n, // $40.00 + 3000n, // $30.00 + 2000n, // $20.00 (total = $90.00) + ]); + + const result = calculateParticipantSplit( + 10000n, // $100.00 + participants, + SplitType.EXACT, + splitShares, + user1, + ); + + expect(result.canSplitScreenClosed).toBe(false); + }); + + it('should handle undefined exact amounts as 0', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EXACT, [ + 10000n, // $100.00 + 0n, // $0.00 + 0n, // $0.00 + ]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.EXACT, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(0n); // Payer: -10000n + 10000n + expect(result.participants[1]?.amount).toBe(0n); + expect(result.participants[2]?.amount).toBe(0n); + }); + }); + + describe('SplitType.ADJUSTMENT', () => { + it('should distribute remaining amount equally after adjustments', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ + 1000n, // +$10.00 adjustment + -500n, // -$5.00 adjustment + 0n, // No adjustment + ]); + + const result = calculateParticipantSplit( + 15000n, // $150.00 + participants, + SplitType.ADJUSTMENT, + splitShares, + user1, + ); + + // Remaining after adjustments: 15000n - 1000n - (-500n) - 0n = 14500n + // Split equally: 14500n / 3 = ~4833n each + const baseShare = (15000n - 1000n - -500n - 0n) / 3n; // 4833n + + expect(result.participants[0]?.amount).toBe(9166n); // Payer adjustment (adjusted for rounding) + expect(result.participants[1]?.amount).toBe(-(baseShare - 500n)); + expect(result.participants[2]?.amount).toBe(-baseShare); + }); + + it('should mark incomplete when adjustments exceed total amount', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ + 8000n, // Large positive adjustment + 5000n, // Another large adjustment + 0n, + ]); + + const result = calculateParticipantSplit( + 10000n, // Total amount smaller than adjustments + participants, + SplitType.ADJUSTMENT, + splitShares, + user1, + ); + + expect(result.canSplitScreenClosed).toBe(false); + }); + }); + + describe('Payer scenarios', () => { + it('should handle different payers correctly', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + // Test with user2 as payer + const result = calculateParticipantSplit( + 30000n, + participants, + SplitType.EQUAL, + splitShares, + user2, + ); + + expect(result.participants[0]?.amount).toBe(-10000n); // Owes + expect(result.participants[1]?.amount).toBe(20000n); // Payer: -10000n + 30000n + expect(result.participants[2]?.amount).toBe(-10000n); // Owes + }); + + it('should handle when payer is not in participants list', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + const externalPayer = createMockUser(4, 'David', 'david@example.com'); + + const result = calculateParticipantSplit( + 30000n, + participants, + SplitType.EQUAL, + splitShares, + externalPayer, + ); // All participants should owe their share but total balances to 0 due to penny adjustment + expect(result.participants[0]?.amount).toBe(0n); + expect(result.participants[1]?.amount).toBe(0n); + expect(result.participants[2]?.amount).toBe(0n); + }); + }); + + describe('Balance verification', () => { + it('should always maintain balance (total amounts sum to 0)', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ + 4000n, // 40% + 3500n, // 35% + 2500n, // 25% + ]); + + const result = calculateParticipantSplit( + 12345n, // Odd amount + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); + + expect(totalAmount).toBe(0n); + }); + + it('should handle penny adjustments correctly', () => { + const participants = createParticipants([user1, user2]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n]); + + const result = calculateParticipantSplit( + 1n, // $0.01 + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // One participant should get the penny + const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); + + expect(totalAmount).toBe(0n); + }); + }); +}); + +describe('calculateSplitShareBasedOnAmount', () => { + describe('SplitType.EQUAL', () => { + it('should set equal shares for participants with non-zero amounts', () => { + const participants = createParticipants([user1, user2, user3], [5000n, 5000n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); + + expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); + expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); + expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); // Zero amount + }); + }); + + describe('SplitType.PERCENTAGE', () => { + it('should calculate percentage shares for regular participants', () => { + const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.PERCENTAGE, splitShares); + + expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% + expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% + expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% + }); + + it('should calculate percentage shares when payer is specified', () => { + const participants = createParticipants([user1, user2, user3], [8000n, -3000n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 10000n, + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + // user1 is payer: paid 10000n but their amount is 8000n, so they owe 2000n (20%) + expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% + expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% + expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% + }); + + it('should handle zero amount', () => { + const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(0n, participants, SplitType.PERCENTAGE, splitShares); + + expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(0n); + expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(0n); + expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(0n); + }); + }); + + describe('SplitType.SHARE', () => { + it('should calculate share values based on participant amounts', () => { + const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares); + + // Shares should be proportional: 6000:3000:3000 = 2:1:1 + // Multiplied by 100 and normalized + expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); // 2 shares * 100 + expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 + expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 + }); + + it('should handle payer in share calculation', () => { + const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares, user1); + + // user1 is payer: paid 12000n but their amount is 6000n, so they owe 6000n + // Shares: 6000:3000:3000 = 2:1:1 + expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); + expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); + expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); + }); + + it('should handle zero amount', () => { + const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(0n, participants, SplitType.SHARE, splitShares); + + expect(splitShares[user1.id]![SplitType.SHARE]).toBe(0n); + expect(splitShares[user2.id]![SplitType.SHARE]).toBe(0n); + expect(splitShares[user3.id]![SplitType.SHARE]).toBe(0n); + }); + }); + + describe('SplitType.EXACT', () => { + it('should set exact amounts for regular participants', () => { + const participants = createParticipants([user1, user2, user3], [-4000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); + + expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); + expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); + expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); + }); + + it('should handle payer in exact calculation', () => { + const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares, user1); + + // user1 is payer: paid 10000n but their amount is 6000n, so they owe 4000n + expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); + expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); + expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); + }); + + it('should handle zero amounts', () => { + const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); + + expect(splitShares[user1.id]![SplitType.EXACT]).toBe(0n); + expect(splitShares[user2.id]![SplitType.EXACT]).toBe(0n); + expect(splitShares[user3.id]![SplitType.EXACT]).toBe(0n); + }); + }); + + describe('SplitType.ADJUSTMENT', () => { + it('should handle payer in adjustment calculation', () => { + const participants = createParticipants([user1, user2, user3], [-9500n, -4500n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 15000n, + participants, + SplitType.ADJUSTMENT, + splitShares, + user1, + ); + + expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(1000n); + expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); + expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(500n); + }); + + it('should handle participants with zero amounts', () => { + const participants = createParticipants([user1, user2, user3], [0n, -5000n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(5000n, participants, SplitType.ADJUSTMENT, splitShares); + + // Only user2 has non-zero amount, so equal share = 5000n / 1 = 5000n + // user2 owes 5000n vs 5000n = 0n adjustment + expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n + expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); // 5000n - 5000n + expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n + }); + }); + + describe('Edge cases', () => { + it('should handle empty participants array', () => { + const participants: Participant[] = []; + const splitShares: Record> = {}; + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares); + + // Should not throw an error and splitShares should remain empty + expect(Object.keys(splitShares)).toHaveLength(0); + }); + + it('should handle when one participant owes entire amount', () => { + const participants = createParticipants([user1, user2], [10000n, -10000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); + + expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n); + expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); + }); + + it('should handle self-payment (no money flow) scenario', () => { + const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); + + expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); + expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n); + expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); + }); + + it('should handle undefined paidBy', () => { + const participants = createParticipants([user1, user2], [-5000n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 10000n, + participants, + SplitType.PERCENTAGE, + splitShares, + undefined, + ); + + expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(5000n); + expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(5000n); + }); + + it('should handle all split types with same data', () => { + const participants = createParticipants([user1, user2], [-6000n, -4000n]); + + Object.values(SplitType).forEach((splitType) => { + if (splitType === SplitType.SETTLEMENT) { + return; + } // Skip settlement as it's not implemented + + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + // Should not throw for any split type + expect(() => { + calculateSplitShareBasedOnAmount(10000n, participants, splitType, splitShares); + }).not.toThrow(); + }); + }); + }); +}); + +// Integration tests for function reversibility +describe('Function Reversibility Tests', () => { + describe('calculateParticipantSplit -> calculateSplitShareBasedOnAmount', () => { + it('should properly reverse EQUAL split', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [1n, 1n, 1n]; + const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 15000n, + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 15000n, + splitResult.participants, + SplitType.EQUAL, + reversedShares, + user1, + ); + + // Check if we got back the original shares + expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); + }); + + it('should properly reverse PERCENTAGE split', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [5000n, 3000n, 2000n]; // 50%, 30%, 20% + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 20000n, + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 20000n, + splitResult.participants, + SplitType.PERCENTAGE, + reversedShares, + user1, + ); + + // Check if we got back the original percentage shares + expect(reversedShares[user1.id]![SplitType.PERCENTAGE]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.PERCENTAGE]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.PERCENTAGE]).toBe(originalShares[2]); + }); + + it('should properly reverse SHARE split', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [400n, 200n, 200n]; // 2:1:1 ratio + const splitShares = createSplitShares(participants, SplitType.SHARE, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 16000n, + participants, + SplitType.SHARE, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 16000n, + splitResult.participants, + SplitType.SHARE, + reversedShares, + user1, + ); + + // Check if we got back proportional shares (should maintain the 2:1:1 ratio) + const ratio1 = Number(reversedShares[user1.id]![SplitType.SHARE]) / Number(originalShares[0]); + const ratio2 = Number(reversedShares[user2.id]![SplitType.SHARE]) / Number(originalShares[1]); + const ratio3 = Number(reversedShares[user3.id]![SplitType.SHARE]) / Number(originalShares[2]); + + // All ratios should be approximately equal (accounting for rounding) + expect(Math.abs(ratio1 - ratio2)).toBeLessThan(0.1); + expect(Math.abs(ratio2 - ratio3)).toBeLessThan(0.1); + }); + + it('should properly reverse EXACT split', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [6000n, 4000n, 2000n]; + const splitShares = createSplitShares(participants, SplitType.EXACT, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 12000n, + participants, + SplitType.EXACT, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 12000n, + splitResult.participants, + SplitType.EXACT, + reversedShares, + user1, + ); + + // Check if we got back the original exact amounts + expect(reversedShares[user1.id]![SplitType.EXACT]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.EXACT]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.EXACT]).toBe(originalShares[2]); + }); + + it('should handle ADJUSTMENT split reversal (known to have bugs)', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [1000n, 0n, 500n]; // Various adjustments + const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 12300n, + participants, + SplitType.ADJUSTMENT, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 12300n, + splitResult.participants, + SplitType.ADJUSTMENT, + reversedShares, + user1, + ); + + expect(reversedShares[user1.id]![SplitType.ADJUSTMENT]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.ADJUSTMENT]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.ADJUSTMENT]).toBe(originalShares[2]); + }); + + it('should handle edge case with zero amounts', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [1n, 1n, 0n]; // One participant excluded + const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 10000n, + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 10000n, + splitResult.participants, + SplitType.EQUAL, + reversedShares, + user1, + ); + + // Check if we preserved the zero share + expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); + }); + + it('should handle external payer scenario', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [1n, 1n, 1n]; + const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); + const externalPayer = createMockUser(4, 'External', 'external@example.com'); + + // Apply split calculation with external payer + const splitResult = calculateParticipantSplit( + 15000n, + participants, + SplitType.EQUAL, + splitShares, + externalPayer, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 15000n, + splitResult.participants, + SplitType.EQUAL, + reversedShares, + externalPayer, + ); + + // With external payer, all participants have 0 amounts due to balance adjustment + // So reversed shares should all be 0 + expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(0n); + expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(0n); + expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(0n); + }); + }); +}); + +describe('Penny distribution preservation', () => { + beforeEach(() => { + useAddExpenseStore.getState().actions.resetState(); + }); + + it('should preserve different penny distributions when editing expenses', () => { + const store = useAddExpenseStore.getState(); + + store.actions.setCurrentUser(user1); + store.actions.setPaidBy(user1); + store.actions.setAmount(4n); + + const distributionA: Participant[] = [ + { ...user1, amount: 3n }, + { ...user2, amount: -2n }, + { ...user3, amount: -1n }, + ]; + + store.actions.setParticipants(distributionA, SplitType.EQUAL); + const resultA = useAddExpenseStore.getState().participants; + + store.actions.resetState(); + store.actions.setCurrentUser(user1); + store.actions.setPaidBy(user1); + store.actions.setAmount(4n); + + const distributionB: Participant[] = [ + { ...user1, amount: 2n }, + { ...user2, amount: -1n }, + { ...user3, amount: -1n }, + ]; + + store.actions.setParticipants(distributionB, SplitType.EQUAL); + const resultB = useAddExpenseStore.getState().participants; + + expect(resultA[0]!.amount).toBe(3n); + expect(resultA[1]!.amount).toBe(-2n); + expect(resultA[2]!.amount).toBe(-1n); + + expect(resultB[0]!.amount).toBe(2n); + expect(resultB[1]!.amount).toBe(-1n); + expect(resultB[2]!.amount).toBe(-1n); + + expect(resultA[0]!.amount).not.toBe(resultB[0]!.amount); + }); + + it('should maintain amounts across multiple edits', () => { + const store = useAddExpenseStore.getState(); + + store.actions.setCurrentUser(user1); + store.actions.setPaidBy(user1); + store.actions.setAmount(4n); + + const edit1: Participant[] = [ + { ...user1, amount: 3n }, + { ...user2, amount: -2n }, + { ...user3, amount: -1n }, + ]; + store.actions.setParticipants(edit1, SplitType.EQUAL); + let result = useAddExpenseStore.getState().participants; + expect(result[0]!.amount).toBe(3n); + expect(result[1]!.amount).toBe(-2n); + + const edit2: Participant[] = [ + { ...user1, amount: 2n }, + { ...user2, amount: -1n }, + { ...user3, amount: -1n }, + ]; + store.actions.setParticipants(edit2, SplitType.EQUAL); + result = useAddExpenseStore.getState().participants; + expect(result[0]!.amount).toBe(2n); + expect(result[1]!.amount).toBe(-1n); + + store.actions.setParticipants(edit1, SplitType.EQUAL); + result = useAddExpenseStore.getState().participants; + expect(result[0]!.amount).toBe(3n); + expect(result[1]!.amount).toBe(-2n); + }); + +}); \ No newline at end of file diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 596b6128..fe6bf293 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -5,10 +5,11 @@ import { create } from 'zustand'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { type CurrencyCode } from '~/lib/currency'; import type { TransactionAddInputModel } from '~/types'; +import { shuffleArray } from '~/utils/array'; import { BigMath } from '~/utils/numbers'; export type Participant = User & { amount?: bigint }; -type SplitShares = Record>; +export type SplitShares = Record>; export interface AddExpenseState { amount: bigint; @@ -102,8 +103,7 @@ export const useAddExpenseStore = create()((set) => ({ s.participants, s.splitType, s.splitShares, - s.paidBy, - s.transactionId + s.paidBy ), }; }), @@ -116,8 +116,7 @@ export const useAddExpenseStore = create()((set) => ({ state.participants, splitType, state.splitShares, - state.paidBy, - state.transactionId + state.paidBy ), })), setSplitShare: (splitType, userId, share) => @@ -162,8 +161,7 @@ export const useAddExpenseStore = create()((set) => ({ participants, state.splitType, splitShares, - state.paidBy, - state.transactionId + state.paidBy ), }; }), @@ -173,6 +171,32 @@ export const useAddExpenseStore = create()((set) => ({ res[p.id] = initSplitShares(); return res; }, {}); + + + const hasExistingAmounts = participants.some((p) => p.amount !== undefined && p.amount !== 0n); + + if (hasExistingAmounts) { + + if (splitType) { + calculateSplitShareBasedOnAmount( + state.amount, + participants, + splitType, + splitShares, + state.paidBy, + ); + } else { + splitType = SplitType.EQUAL; + } + return { + splitType, + splitShares, + participants, + canSplitScreenClosed: true, + }; + } + + if (splitType) { calculateSplitShareBasedOnAmount( state.amount, @@ -192,8 +216,7 @@ export const useAddExpenseStore = create()((set) => ({ participants, splitType, splitShares, - state.paidBy, - state.transactionId + state.paidBy ), }; }), @@ -307,8 +330,7 @@ export function calculateParticipantSplit( participants: Participant[], splitType: SplitType, splitShares: SplitShares, - paidBy?: Participant, - transactionId: string = '' + paidBy?: Participant ) { let canSplitScreenClosed = true; if (0n === amount) { @@ -374,52 +396,19 @@ export function calculateParticipantSplit( return { ...p, amount: -(p.amount ?? 0n) }; }); - let penniesLeft = updatedParticipants.reduce((acc, p) => acc + (p.amount ?? 0n), 0n); - const participantsToPick = updatedParticipants.filter((p) => p.amount); - - if (0 < participantsToPick.length) { - - const hash = (value: number, inputs: { amount: bigint; transactionId: string }): number => { - let hash = 5381; - - // Hash the transactionId first to give it more weight - for (let i = 0; i < inputs.transactionId.length; i++) { - const char = inputs.transactionId.charCodeAt(i); - hash = ((hash << 5) + hash) + char; - hash = hash & hash; - } - - // Multiply by a large prime to amplify the transaction ID's influence - hash *= 31; - - // Then add amount's influence - const amountStr = inputs.amount.toString(); - const lastDigits = amountStr.slice(-6); - for (let i = 0; i < lastDigits.length; i++) { - const char = lastDigits.charCodeAt(i); - hash = ((hash << 5) + hash) + char; - hash = hash & hash; + if (canSplitScreenClosed) { + let penniesLeft = updatedParticipants.reduce((acc, p) => acc + (p.amount ?? 0n), 0n); + const participantsToPick = updatedParticipants.filter((p) => p.amount); + + if (0 < participantsToPick.length) { + shuffleArray(participantsToPick); + let i = 0; + while (0n !== penniesLeft) { + const p = participantsToPick[i % participantsToPick.length]!; + p.amount! -= BigMath.sign(penniesLeft); + penniesLeft -= BigMath.sign(penniesLeft); + i++; } - - // Finally add participant's influence - hash = ((hash << 5) + hash) + value; - hash = hash & hash; - - return Math.abs(hash); - }; - - const shuffled = [...participantsToPick].sort((a, b) => { - const hashA = hash(a.id, { amount, transactionId }); - const hashB = hash(b.id, { amount, transactionId }); - return hashA - hashB; - }); - - let i = 0; - while (0n !== penniesLeft) { - const p = shuffled[i % shuffled.length]!; - p.amount! -= BigMath.sign(penniesLeft); - penniesLeft -= BigMath.sign(penniesLeft); - i++; } } @@ -440,7 +429,8 @@ export function calculateSplitShareBasedOnAmount( switch (splitType) { case SplitType.EQUAL: participants.forEach((p) => { - splitShares[p.id]![splitType] = 0n === p.amount && participants.length > 1 ? 0n : 1n; + splitShares[p.id]![splitType] = + (p.id === paidBy?.id ? amount : 0n) === p.amount && participants.length > 1 ? 0n : 1n; }); break; From 22685b707bca6f58bfe5826f1f05e36b376101cd Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 4 Nov 2025 23:22:17 +0100 Subject: [PATCH 5/5] fixed incorrect diff showing in addStore.test.ts --- src/store/addStore.test.ts | 2137 ++++++++++++++++++------------------ 1 file changed, 1068 insertions(+), 1069 deletions(-) diff --git a/src/store/addStore.test.ts b/src/store/addStore.test.ts index 8a3eb519..80989f44 100644 --- a/src/store/addStore.test.ts +++ b/src/store/addStore.test.ts @@ -1,1069 +1,1068 @@ -import { SplitType, type User } from '@prisma/client'; - -import { - type Participant, - calculateParticipantSplit, - calculateSplitShareBasedOnAmount, - initSplitShares, - useAddExpenseStore, -} from '~/store/addStore'; - -// Mock dependencies -jest.mock('~/utils/array', () => ({ - shuffleArray: jest.fn((arr: T[]): T[] => arr), // No shuffling for predictable tests -})); - -// Create mock users for testing -const createMockUser = (id: number, name: string, email: string): User => ({ - id, - name, - email, - currency: 'USD', - emailVerified: null, - image: null, - preferredLanguage: 'en', - obapiProviderId: null, - bankingId: null, -}); - -const user1: User = createMockUser(1, 'Alice', 'alice@example.com'); -const user2: User = createMockUser(2, 'Bob', 'bob@example.com'); -const user3: User = createMockUser(3, 'Charlie', 'charlie@example.com'); - -// Create participants with initial amounts -const createParticipants = (users: User[], amounts: bigint[] = []): Participant[] => - users.map((user, index) => ({ - ...user, - amount: amounts[index] ?? 0n, - })); - -// Helper to create split shares structure -const createSplitShares = (participants: Participant[], splitType: SplitType, shares: bigint[]) => { - const splitShares: Record> = {}; - - participants.forEach((participant, index) => { - splitShares[participant.id] = initSplitShares(); - splitShares[participant.id]![splitType] = shares[index] ?? 0n; - }); - - return splitShares; -}; - -describe('calculateParticipantSplit', () => { - describe('Edge cases', () => { - it('should return participants unchanged when amount is 0', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const result = calculateParticipantSplit( - 0n, - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - expect(result.participants).toEqual(participants); - expect(result.canSplitScreenClosed).toBe(true); - }); - it('should handle empty participants array', () => { - const result = calculateParticipantSplit(10000n, [], SplitType.EQUAL, {}, undefined); - - expect(result.participants).toEqual([]); - expect(result.canSplitScreenClosed).toBe(false); - }); - }); - - describe('SplitType.EQUAL', () => { - it('should split amount equally among all participants', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const result = calculateParticipantSplit( - 30000n, // $300.00 - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Each person should owe $100 (10000n), but payer gets the difference - expect(result.participants[0]?.amount).toBe(20000n); // Payer: -10000n + 30000n = 20000n - expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 - expect(result.participants[2]?.amount).toBe(-10000n); // Owes $100 - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should exclude participants with 0 share from equal split', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 0n]); - - const result = calculateParticipantSplit( - 20000n, // $200.00 - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Only first two participants split the cost - expect(result.participants[0]?.amount).toBe(10000n); // Payer: -10000n + 20000n = 10000n - expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 - expect(result.participants[2]?.amount).toBe(0n); // Excluded from split - }); - - it('should handle uneven division with penny adjustment', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const result = calculateParticipantSplit( - 10001n, // $100.01 (not evenly divisible by 3) - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Should distribute the extra penny - const totalOwed = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - expect(totalOwed).toBe(0n); // Total should always balance // Payer should get the remainder - expect(result.participants[0]?.amount).toBeGreaterThanOrEqual(6667n); - }); - - it('should handle single participant as payer', () => { - const participants = createParticipants([user1]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(0n); // Payer pays and owes nothing - expect(result.canSplitScreenClosed).toBe(true); - }); - }); - - describe('SplitType.PERCENTAGE', () => { - it('should split amount by percentage', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 5000n, // 50% - 3000n, // 30% - 2000n, // 20% - ]); - - const result = calculateParticipantSplit( - 10000n, // $100.00 - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(5000n); // Payer: -5000n + 10000n = 5000n - expect(result.participants[1]?.amount).toBe(-3000n); // Owes 30% - expect(result.participants[2]?.amount).toBe(-2000n); // Owes 20% - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should mark incomplete when percentages do not sum to 100%', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 4000n, // 40% - 3000n, // 30% - 2000n, // 20% (total = 90%) - ]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - expect(result.canSplitScreenClosed).toBe(false); - }); - - it('should handle 0% share participants', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 7000n, // 70% - 3000n, // 30% - 0n, // 0% - ]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(3000n); // Payer: -7000n + 10000n - expect(result.participants[1]?.amount).toBe(-3000n); - expect(result.participants[2]?.amount).toBe(0n); // No share - }); - }); - - describe('SplitType.SHARE', () => { - it('should split amount by shares', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.SHARE, [ - 200n, // 2 shares - 100n, // 1 share - 100n, // 1 share (total = 4 shares) - ]); - - const result = calculateParticipantSplit( - 12000n, // $120.00 - participants, - SplitType.SHARE, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(6000n); // Payer: -6000n + 12000n = 6000n (50%) - expect(result.participants[1]?.amount).toBe(-3000n); // 25% - expect(result.participants[2]?.amount).toBe(-3000n); // 25% - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should handle participants with 0 shares', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.SHARE, [ - 300n, // 3 shares - 100n, // 1 share - 0n, // 0 shares - ]); - - const result = calculateParticipantSplit( - 8000n, - participants, - SplitType.SHARE, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(2000n); // Payer: -6000n + 8000n (75%) - expect(result.participants[1]?.amount).toBe(-2000n); // 25% - expect(result.participants[2]?.amount).toBe(0n); // No shares - }); - - it('should mark incomplete when no shares are assigned', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.SHARE, [0n, 0n, 0n]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.SHARE, - splitShares, - user1, - ); - - expect(result.canSplitScreenClosed).toBe(false); - }); - }); - - describe('SplitType.EXACT', () => { - it('should assign exact amounts to participants', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EXACT, [ - 4000n, // $40.00 - 3000n, // $30.00 - 3000n, // $30.00 - ]); - - const result = calculateParticipantSplit( - 10000n, // $100.00 - participants, - SplitType.EXACT, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(6000n); // Payer: -4000n + 10000n - expect(result.participants[1]?.amount).toBe(-3000n); - expect(result.participants[2]?.amount).toBe(-3000n); - expect(result.canSplitScreenClosed).toBe(true); - }); - - it('should mark incomplete when exact amounts do not sum to total', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EXACT, [ - 4000n, // $40.00 - 3000n, // $30.00 - 2000n, // $20.00 (total = $90.00) - ]); - - const result = calculateParticipantSplit( - 10000n, // $100.00 - participants, - SplitType.EXACT, - splitShares, - user1, - ); - - expect(result.canSplitScreenClosed).toBe(false); - }); - - it('should handle undefined exact amounts as 0', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EXACT, [ - 10000n, // $100.00 - 0n, // $0.00 - 0n, // $0.00 - ]); - - const result = calculateParticipantSplit( - 10000n, - participants, - SplitType.EXACT, - splitShares, - user1, - ); - - expect(result.participants[0]?.amount).toBe(0n); // Payer: -10000n + 10000n - expect(result.participants[1]?.amount).toBe(0n); - expect(result.participants[2]?.amount).toBe(0n); - }); - }); - - describe('SplitType.ADJUSTMENT', () => { - it('should distribute remaining amount equally after adjustments', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ - 1000n, // +$10.00 adjustment - -500n, // -$5.00 adjustment - 0n, // No adjustment - ]); - - const result = calculateParticipantSplit( - 15000n, // $150.00 - participants, - SplitType.ADJUSTMENT, - splitShares, - user1, - ); - - // Remaining after adjustments: 15000n - 1000n - (-500n) - 0n = 14500n - // Split equally: 14500n / 3 = ~4833n each - const baseShare = (15000n - 1000n - -500n - 0n) / 3n; // 4833n - - expect(result.participants[0]?.amount).toBe(9166n); // Payer adjustment (adjusted for rounding) - expect(result.participants[1]?.amount).toBe(-(baseShare - 500n)); - expect(result.participants[2]?.amount).toBe(-baseShare); - }); - - it('should mark incomplete when adjustments exceed total amount', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ - 8000n, // Large positive adjustment - 5000n, // Another large adjustment - 0n, - ]); - - const result = calculateParticipantSplit( - 10000n, // Total amount smaller than adjustments - participants, - SplitType.ADJUSTMENT, - splitShares, - user1, - ); - - expect(result.canSplitScreenClosed).toBe(false); - }); - }); - - describe('Payer scenarios', () => { - it('should handle different payers correctly', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - // Test with user2 as payer - const result = calculateParticipantSplit( - 30000n, - participants, - SplitType.EQUAL, - splitShares, - user2, - ); - - expect(result.participants[0]?.amount).toBe(-10000n); // Owes - expect(result.participants[1]?.amount).toBe(20000n); // Payer: -10000n + 30000n - expect(result.participants[2]?.amount).toBe(-10000n); // Owes - }); - - it('should handle when payer is not in participants list', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); - - const externalPayer = createMockUser(4, 'David', 'david@example.com'); - - const result = calculateParticipantSplit( - 30000n, - participants, - SplitType.EQUAL, - splitShares, - externalPayer, - ); // All participants should owe their share but total balances to 0 due to penny adjustment - expect(result.participants[0]?.amount).toBe(0n); - expect(result.participants[1]?.amount).toBe(0n); - expect(result.participants[2]?.amount).toBe(0n); - }); - }); - - describe('Balance verification', () => { - it('should always maintain balance (total amounts sum to 0)', () => { - const participants = createParticipants([user1, user2, user3]); - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ - 4000n, // 40% - 3500n, // 35% - 2500n, // 25% - ]); - - const result = calculateParticipantSplit( - 12345n, // Odd amount - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - - expect(totalAmount).toBe(0n); - }); - - it('should handle penny adjustments correctly', () => { - const participants = createParticipants([user1, user2]); - const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n]); - - const result = calculateParticipantSplit( - 1n, // $0.01 - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // One participant should get the penny - const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); - - expect(totalAmount).toBe(0n); - }); - }); -}); - -describe('calculateSplitShareBasedOnAmount', () => { - describe('SplitType.EQUAL', () => { - it('should set equal shares for participants with non-zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [5000n, 5000n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); // Zero amount - }); - }); - - describe('SplitType.PERCENTAGE', () => { - it('should calculate percentage shares for regular participants', () => { - const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.PERCENTAGE, splitShares); - - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% - expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% - }); - - it('should calculate percentage shares when payer is specified', () => { - const participants = createParticipants([user1, user2, user3], [8000n, -3000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - // user1 is payer: paid 10000n but their amount is 8000n, so they owe 2000n (20%) - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% - expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% - }); - - it('should handle zero amount', () => { - const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(0n, participants, SplitType.PERCENTAGE, splitShares); - - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(0n); - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(0n); - expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(0n); - }); - }); - - describe('SplitType.SHARE', () => { - it('should calculate share values based on participant amounts', () => { - const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares); - - // Shares should be proportional: 6000:3000:3000 = 2:1:1 - // Multiplied by 100 and normalized - expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); // 2 shares * 100 - expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 - expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 - }); - - it('should handle payer in share calculation', () => { - const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares, user1); - - // user1 is payer: paid 12000n but their amount is 6000n, so they owe 6000n - // Shares: 6000:3000:3000 = 2:1:1 - expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); - expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); - expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); - }); - - it('should handle zero amount', () => { - const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(0n, participants, SplitType.SHARE, splitShares); - - expect(splitShares[user1.id]![SplitType.SHARE]).toBe(0n); - expect(splitShares[user2.id]![SplitType.SHARE]).toBe(0n); - expect(splitShares[user3.id]![SplitType.SHARE]).toBe(0n); - }); - }); - - describe('SplitType.EXACT', () => { - it('should set exact amounts for regular participants', () => { - const participants = createParticipants([user1, user2, user3], [-4000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); - - expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); - expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); - expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); - }); - - it('should handle payer in exact calculation', () => { - const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares, user1); - - // user1 is payer: paid 10000n but their amount is 6000n, so they owe 4000n - expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); - expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); - expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); - }); - - it('should handle zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); - - expect(splitShares[user1.id]![SplitType.EXACT]).toBe(0n); - expect(splitShares[user2.id]![SplitType.EXACT]).toBe(0n); - expect(splitShares[user3.id]![SplitType.EXACT]).toBe(0n); - }); - }); - - describe('SplitType.ADJUSTMENT', () => { - it('should handle payer in adjustment calculation', () => { - const participants = createParticipants([user1, user2, user3], [-9500n, -4500n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 15000n, - participants, - SplitType.ADJUSTMENT, - splitShares, - user1, - ); - - expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(1000n); - expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); - expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(500n); - }); - - it('should handle participants with zero amounts', () => { - const participants = createParticipants([user1, user2, user3], [0n, -5000n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(5000n, participants, SplitType.ADJUSTMENT, splitShares); - - // Only user2 has non-zero amount, so equal share = 5000n / 1 = 5000n - // user2 owes 5000n vs 5000n = 0n adjustment - expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n - expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); // 5000n - 5000n - expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n - }); - }); - - describe('Edge cases', () => { - it('should handle empty participants array', () => { - const participants: Participant[] = []; - const splitShares: Record> = {}; - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares); - - // Should not throw an error and splitShares should remain empty - expect(Object.keys(splitShares)).toHaveLength(0); - }); - - it('should handle when one participant owes entire amount', () => { - const participants = createParticipants([user1, user2], [10000n, -10000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); - }); - - it('should handle self-payment (no money flow) scenario', () => { - const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); - - expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); - expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n); - expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); - }); - - it('should handle undefined paidBy', () => { - const participants = createParticipants([user1, user2], [-5000n, -5000n]); - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 10000n, - participants, - SplitType.PERCENTAGE, - splitShares, - undefined, - ); - - expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(5000n); - expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(5000n); - }); - - it('should handle all split types with same data', () => { - const participants = createParticipants([user1, user2], [-6000n, -4000n]); - - Object.values(SplitType).forEach((splitType) => { - if (splitType === SplitType.SETTLEMENT) { - return; - } // Skip settlement as it's not implemented - - const splitShares: Record> = {}; - participants.forEach((p) => { - splitShares[p.id] = initSplitShares(); - }); - - // Should not throw for any split type - expect(() => { - calculateSplitShareBasedOnAmount(10000n, participants, splitType, splitShares); - }).not.toThrow(); - }); - }); - }); -}); - -// Integration tests for function reversibility -describe('Function Reversibility Tests', () => { - describe('calculateParticipantSplit -> calculateSplitShareBasedOnAmount', () => { - it('should properly reverse EQUAL split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1n, 1n, 1n]; - const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 15000n, - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 15000n, - splitResult.participants, - SplitType.EQUAL, - reversedShares, - user1, - ); - - // Check if we got back the original shares - expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); - }); - - it('should properly reverse PERCENTAGE split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [5000n, 3000n, 2000n]; // 50%, 30%, 20% - const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 20000n, - participants, - SplitType.PERCENTAGE, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 20000n, - splitResult.participants, - SplitType.PERCENTAGE, - reversedShares, - user1, - ); - - // Check if we got back the original percentage shares - expect(reversedShares[user1.id]![SplitType.PERCENTAGE]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.PERCENTAGE]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.PERCENTAGE]).toBe(originalShares[2]); - }); - - it('should properly reverse SHARE split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [400n, 200n, 200n]; // 2:1:1 ratio - const splitShares = createSplitShares(participants, SplitType.SHARE, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 16000n, - participants, - SplitType.SHARE, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 16000n, - splitResult.participants, - SplitType.SHARE, - reversedShares, - user1, - ); - - // Check if we got back proportional shares (should maintain the 2:1:1 ratio) - const ratio1 = Number(reversedShares[user1.id]![SplitType.SHARE]) / Number(originalShares[0]); - const ratio2 = Number(reversedShares[user2.id]![SplitType.SHARE]) / Number(originalShares[1]); - const ratio3 = Number(reversedShares[user3.id]![SplitType.SHARE]) / Number(originalShares[2]); - - // All ratios should be approximately equal (accounting for rounding) - expect(Math.abs(ratio1 - ratio2)).toBeLessThan(0.1); - expect(Math.abs(ratio2 - ratio3)).toBeLessThan(0.1); - }); - - it('should properly reverse EXACT split', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [6000n, 4000n, 2000n]; - const splitShares = createSplitShares(participants, SplitType.EXACT, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 12000n, - participants, - SplitType.EXACT, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 12000n, - splitResult.participants, - SplitType.EXACT, - reversedShares, - user1, - ); - - // Check if we got back the original exact amounts - expect(reversedShares[user1.id]![SplitType.EXACT]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.EXACT]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.EXACT]).toBe(originalShares[2]); - }); - - it('should handle ADJUSTMENT split reversal (known to have bugs)', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1000n, 0n, 500n]; // Various adjustments - const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 12300n, - participants, - SplitType.ADJUSTMENT, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 12300n, - splitResult.participants, - SplitType.ADJUSTMENT, - reversedShares, - user1, - ); - - expect(reversedShares[user1.id]![SplitType.ADJUSTMENT]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.ADJUSTMENT]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.ADJUSTMENT]).toBe(originalShares[2]); - }); - - it('should handle edge case with zero amounts', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1n, 1n, 0n]; // One participant excluded - const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); - - // Apply split calculation - const splitResult = calculateParticipantSplit( - 10000n, - participants, - SplitType.EQUAL, - splitShares, - user1, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 10000n, - splitResult.participants, - SplitType.EQUAL, - reversedShares, - user1, - ); - - // Check if we preserved the zero share - expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); - expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); - expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); - }); - - it('should handle external payer scenario', () => { - const participants = createParticipants([user1, user2, user3]); - const originalShares = [1n, 1n, 1n]; - const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); - const externalPayer = createMockUser(4, 'External', 'external@example.com'); - - // Apply split calculation with external payer - const splitResult = calculateParticipantSplit( - 15000n, - participants, - SplitType.EQUAL, - splitShares, - externalPayer, - ); - - // Reverse the calculation - const reversedShares: Record> = {}; - participants.forEach((p) => { - reversedShares[p.id] = initSplitShares(); - }); - - calculateSplitShareBasedOnAmount( - 15000n, - splitResult.participants, - SplitType.EQUAL, - reversedShares, - externalPayer, - ); - - // With external payer, all participants have 0 amounts due to balance adjustment - // So reversed shares should all be 0 - expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(0n); - expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(0n); - expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(0n); - }); - }); -}); - -describe('Penny distribution preservation', () => { - beforeEach(() => { - useAddExpenseStore.getState().actions.resetState(); - }); - - it('should preserve different penny distributions when editing expenses', () => { - const store = useAddExpenseStore.getState(); - - store.actions.setCurrentUser(user1); - store.actions.setPaidBy(user1); - store.actions.setAmount(4n); - - const distributionA: Participant[] = [ - { ...user1, amount: 3n }, - { ...user2, amount: -2n }, - { ...user3, amount: -1n }, - ]; - - store.actions.setParticipants(distributionA, SplitType.EQUAL); - const resultA = useAddExpenseStore.getState().participants; - - store.actions.resetState(); - store.actions.setCurrentUser(user1); - store.actions.setPaidBy(user1); - store.actions.setAmount(4n); - - const distributionB: Participant[] = [ - { ...user1, amount: 2n }, - { ...user2, amount: -1n }, - { ...user3, amount: -1n }, - ]; - - store.actions.setParticipants(distributionB, SplitType.EQUAL); - const resultB = useAddExpenseStore.getState().participants; - - expect(resultA[0]!.amount).toBe(3n); - expect(resultA[1]!.amount).toBe(-2n); - expect(resultA[2]!.amount).toBe(-1n); - - expect(resultB[0]!.amount).toBe(2n); - expect(resultB[1]!.amount).toBe(-1n); - expect(resultB[2]!.amount).toBe(-1n); - - expect(resultA[0]!.amount).not.toBe(resultB[0]!.amount); - }); - - it('should maintain amounts across multiple edits', () => { - const store = useAddExpenseStore.getState(); - - store.actions.setCurrentUser(user1); - store.actions.setPaidBy(user1); - store.actions.setAmount(4n); - - const edit1: Participant[] = [ - { ...user1, amount: 3n }, - { ...user2, amount: -2n }, - { ...user3, amount: -1n }, - ]; - store.actions.setParticipants(edit1, SplitType.EQUAL); - let result = useAddExpenseStore.getState().participants; - expect(result[0]!.amount).toBe(3n); - expect(result[1]!.amount).toBe(-2n); - - const edit2: Participant[] = [ - { ...user1, amount: 2n }, - { ...user2, amount: -1n }, - { ...user3, amount: -1n }, - ]; - store.actions.setParticipants(edit2, SplitType.EQUAL); - result = useAddExpenseStore.getState().participants; - expect(result[0]!.amount).toBe(2n); - expect(result[1]!.amount).toBe(-1n); - - store.actions.setParticipants(edit1, SplitType.EQUAL); - result = useAddExpenseStore.getState().participants; - expect(result[0]!.amount).toBe(3n); - expect(result[1]!.amount).toBe(-2n); - }); - -}); \ No newline at end of file +import { SplitType, type User } from '@prisma/client'; + +import { + type Participant, + calculateParticipantSplit, + calculateSplitShareBasedOnAmount, + initSplitShares, + useAddExpenseStore, +} from './addStore'; + +// Mock dependencies +jest.mock('~/utils/array', () => ({ + shuffleArray: jest.fn((arr: T[]): T[] => arr), // No shuffling for predictable tests +})); + +// Create mock users for testing +const createMockUser = (id: number, name: string, email: string): User => ({ + id, + name, + email, + currency: 'USD', + emailVerified: null, + image: null, + preferredLanguage: 'en', + obapiProviderId: null, + bankingId: null, +}); + +const user1: User = createMockUser(1, 'Alice', 'alice@example.com'); +const user2: User = createMockUser(2, 'Bob', 'bob@example.com'); +const user3: User = createMockUser(3, 'Charlie', 'charlie@example.com'); + +// Create participants with initial amounts +const createParticipants = (users: User[], amounts: bigint[] = []): Participant[] => + users.map((user, index) => ({ + ...user, + amount: amounts[index] ?? 0n, + })); + +// Helper to create split shares structure +const createSplitShares = (participants: Participant[], splitType: SplitType, shares: bigint[]) => { + const splitShares: Record> = {}; + + participants.forEach((participant, index) => { + splitShares[participant.id] = initSplitShares(); + splitShares[participant.id]![splitType] = shares[index] ?? 0n; + }); + + return splitShares; +}; + +describe('calculateParticipantSplit', () => { + describe('Edge cases', () => { + it('should return participants unchanged when amount is 0', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + const result = calculateParticipantSplit( + 0n, + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + expect(result.participants).toEqual(participants); + expect(result.canSplitScreenClosed).toBe(true); + }); + it('should handle empty participants array', () => { + const result = calculateParticipantSplit(10000n, [], SplitType.EQUAL, {}, undefined); + + expect(result.participants).toEqual([]); + expect(result.canSplitScreenClosed).toBe(false); + }); + }); + + describe('SplitType.EQUAL', () => { + it('should split amount equally among all participants', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + const result = calculateParticipantSplit( + 30000n, // $300.00 + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Each person should owe $100 (10000n), but payer gets the difference + expect(result.participants[0]?.amount).toBe(20000n); // Payer: -10000n + 30000n = 20000n + expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 + expect(result.participants[2]?.amount).toBe(-10000n); // Owes $100 + expect(result.canSplitScreenClosed).toBe(true); + }); + + it('should exclude participants with 0 share from equal split', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 0n]); + + const result = calculateParticipantSplit( + 20000n, // $200.00 + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Only first two participants split the cost + expect(result.participants[0]?.amount).toBe(10000n); // Payer: -10000n + 20000n = 10000n + expect(result.participants[1]?.amount).toBe(-10000n); // Owes $100 + expect(result.participants[2]?.amount).toBe(0n); // Excluded from split + }); + + it('should handle uneven division with penny adjustment', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + const result = calculateParticipantSplit( + 10001n, // $100.01 (not evenly divisible by 3) + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Should distribute the extra penny + const totalOwed = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); + expect(totalOwed).toBe(0n); // Total should always balance // Payer should get the remainder + expect(result.participants[0]?.amount).toBeGreaterThanOrEqual(6667n); + }); + + it('should handle single participant as payer', () => { + const participants = createParticipants([user1]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(0n); // Payer pays and owes nothing + expect(result.canSplitScreenClosed).toBe(true); + }); + }); + + describe('SplitType.PERCENTAGE', () => { + it('should split amount by percentage', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ + 5000n, // 50% + 3000n, // 30% + 2000n, // 20% + ]); + + const result = calculateParticipantSplit( + 10000n, // $100.00 + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(5000n); // Payer: -5000n + 10000n = 5000n + expect(result.participants[1]?.amount).toBe(-3000n); // Owes 30% + expect(result.participants[2]?.amount).toBe(-2000n); // Owes 20% + expect(result.canSplitScreenClosed).toBe(true); + }); + + it('should mark incomplete when percentages do not sum to 100%', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ + 4000n, // 40% + 3000n, // 30% + 2000n, // 20% (total = 90%) + ]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + expect(result.canSplitScreenClosed).toBe(false); + }); + + it('should handle 0% share participants', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ + 7000n, // 70% + 3000n, // 30% + 0n, // 0% + ]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(3000n); // Payer: -7000n + 10000n + expect(result.participants[1]?.amount).toBe(-3000n); + expect(result.participants[2]?.amount).toBe(0n); // No share + }); + }); + + describe('SplitType.SHARE', () => { + it('should split amount by shares', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.SHARE, [ + 200n, // 2 shares + 100n, // 1 share + 100n, // 1 share (total = 4 shares) + ]); + + const result = calculateParticipantSplit( + 12000n, // $120.00 + participants, + SplitType.SHARE, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(6000n); // Payer: -6000n + 12000n = 6000n (50%) + expect(result.participants[1]?.amount).toBe(-3000n); // 25% + expect(result.participants[2]?.amount).toBe(-3000n); // 25% + expect(result.canSplitScreenClosed).toBe(true); + }); + + it('should handle participants with 0 shares', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.SHARE, [ + 300n, // 3 shares + 100n, // 1 share + 0n, // 0 shares + ]); + + const result = calculateParticipantSplit( + 8000n, + participants, + SplitType.SHARE, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(2000n); // Payer: -6000n + 8000n (75%) + expect(result.participants[1]?.amount).toBe(-2000n); // 25% + expect(result.participants[2]?.amount).toBe(0n); // No shares + }); + + it('should mark incomplete when no shares are assigned', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.SHARE, [0n, 0n, 0n]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.SHARE, + splitShares, + user1, + ); + + expect(result.canSplitScreenClosed).toBe(false); + }); + }); + + describe('SplitType.EXACT', () => { + it('should assign exact amounts to participants', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EXACT, [ + 4000n, // $40.00 + 3000n, // $30.00 + 3000n, // $30.00 + ]); + + const result = calculateParticipantSplit( + 10000n, // $100.00 + participants, + SplitType.EXACT, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(6000n); // Payer: -4000n + 10000n + expect(result.participants[1]?.amount).toBe(-3000n); + expect(result.participants[2]?.amount).toBe(-3000n); + expect(result.canSplitScreenClosed).toBe(true); + }); + + it('should mark incomplete when exact amounts do not sum to total', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EXACT, [ + 4000n, // $40.00 + 3000n, // $30.00 + 2000n, // $20.00 (total = $90.00) + ]); + + const result = calculateParticipantSplit( + 10000n, // $100.00 + participants, + SplitType.EXACT, + splitShares, + user1, + ); + + expect(result.canSplitScreenClosed).toBe(false); + }); + + it('should handle undefined exact amounts as 0', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EXACT, [ + 10000n, // $100.00 + 0n, // $0.00 + 0n, // $0.00 + ]); + + const result = calculateParticipantSplit( + 10000n, + participants, + SplitType.EXACT, + splitShares, + user1, + ); + + expect(result.participants[0]?.amount).toBe(0n); // Payer: -10000n + 10000n + expect(result.participants[1]?.amount).toBe(0n); + expect(result.participants[2]?.amount).toBe(0n); + }); + }); + + describe('SplitType.ADJUSTMENT', () => { + it('should distribute remaining amount equally after adjustments', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ + 1000n, // +$10.00 adjustment + -500n, // -$5.00 adjustment + 0n, // No adjustment + ]); + + const result = calculateParticipantSplit( + 15000n, // $150.00 + participants, + SplitType.ADJUSTMENT, + splitShares, + user1, + ); + + // Remaining after adjustments: 15000n - 1000n - (-500n) - 0n = 14500n + // Split equally: 14500n / 3 = ~4833n each + const baseShare = (15000n - 1000n - -500n - 0n) / 3n; // 4833n + + expect(result.participants[0]?.amount).toBe(9166n); // Payer adjustment (adjusted for rounding) + expect(result.participants[1]?.amount).toBe(-(baseShare - 500n)); + expect(result.participants[2]?.amount).toBe(-baseShare); + }); + + it('should mark incomplete when adjustments exceed total amount', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, [ + 8000n, // Large positive adjustment + 5000n, // Another large adjustment + 0n, + ]); + + const result = calculateParticipantSplit( + 10000n, // Total amount smaller than adjustments + participants, + SplitType.ADJUSTMENT, + splitShares, + user1, + ); + + expect(result.canSplitScreenClosed).toBe(false); + }); + }); + + describe('Payer scenarios', () => { + it('should handle different payers correctly', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + // Test with user2 as payer + const result = calculateParticipantSplit( + 30000n, + participants, + SplitType.EQUAL, + splitShares, + user2, + ); + + expect(result.participants[0]?.amount).toBe(-10000n); // Owes + expect(result.participants[1]?.amount).toBe(20000n); // Payer: -10000n + 30000n + expect(result.participants[2]?.amount).toBe(-10000n); // Owes + }); + + it('should handle when payer is not in participants list', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]); + + const externalPayer = createMockUser(4, 'David', 'david@example.com'); + + const result = calculateParticipantSplit( + 30000n, + participants, + SplitType.EQUAL, + splitShares, + externalPayer, + ); // All participants should owe their share but total balances to 0 due to penny adjustment + expect(result.participants[0]?.amount).toBe(0n); + expect(result.participants[1]?.amount).toBe(0n); + expect(result.participants[2]?.amount).toBe(0n); + }); + }); + + describe('Balance verification', () => { + it('should always maintain balance (total amounts sum to 0)', () => { + const participants = createParticipants([user1, user2, user3]); + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, [ + 4000n, // 40% + 3500n, // 35% + 2500n, // 25% + ]); + + const result = calculateParticipantSplit( + 12345n, // Odd amount + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); + + expect(totalAmount).toBe(0n); + }); + + it('should handle penny adjustments correctly', () => { + const participants = createParticipants([user1, user2]); + const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n]); + + const result = calculateParticipantSplit( + 1n, // $0.01 + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // One participant should get the penny + const totalAmount = result.participants.reduce((sum, p) => sum + (p.amount ?? 0n), 0n); + + expect(totalAmount).toBe(0n); + }); + }); +}); + +describe('calculateSplitShareBasedOnAmount', () => { + describe('SplitType.EQUAL', () => { + it('should set equal shares for participants with non-zero amounts', () => { + const participants = createParticipants([user1, user2, user3], [5000n, 5000n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); + + expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); + expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); + expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); // Zero amount + }); + }); + + describe('SplitType.PERCENTAGE', () => { + it('should calculate percentage shares for regular participants', () => { + const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.PERCENTAGE, splitShares); + + expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% + expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% + expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% + }); + + it('should calculate percentage shares when payer is specified', () => { + const participants = createParticipants([user1, user2, user3], [8000n, -3000n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 10000n, + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + // user1 is payer: paid 10000n but their amount is 8000n, so they owe 2000n (20%) + expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(2000n); // 20% + expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(3000n); // 30% + expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(5000n); // 50% + }); + + it('should handle zero amount', () => { + const participants = createParticipants([user1, user2, user3], [-3000n, -2000n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(0n, participants, SplitType.PERCENTAGE, splitShares); + + expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(0n); + expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(0n); + expect(splitShares[user3.id]![SplitType.PERCENTAGE]).toBe(0n); + }); + }); + + describe('SplitType.SHARE', () => { + it('should calculate share values based on participant amounts', () => { + const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares); + + // Shares should be proportional: 6000:3000:3000 = 2:1:1 + // Multiplied by 100 and normalized + expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); // 2 shares * 100 + expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 + expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); // 1 share * 100 + }); + + it('should handle payer in share calculation', () => { + const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(12000n, participants, SplitType.SHARE, splitShares, user1); + + // user1 is payer: paid 12000n but their amount is 6000n, so they owe 6000n + // Shares: 6000:3000:3000 = 2:1:1 + expect(splitShares[user1.id]![SplitType.SHARE]).toBe(200n); + expect(splitShares[user2.id]![SplitType.SHARE]).toBe(100n); + expect(splitShares[user3.id]![SplitType.SHARE]).toBe(100n); + }); + + it('should handle zero amount', () => { + const participants = createParticipants([user1, user2, user3], [-6000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(0n, participants, SplitType.SHARE, splitShares); + + expect(splitShares[user1.id]![SplitType.SHARE]).toBe(0n); + expect(splitShares[user2.id]![SplitType.SHARE]).toBe(0n); + expect(splitShares[user3.id]![SplitType.SHARE]).toBe(0n); + }); + }); + + describe('SplitType.EXACT', () => { + it('should set exact amounts for regular participants', () => { + const participants = createParticipants([user1, user2, user3], [-4000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); + + expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); + expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); + expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); + }); + + it('should handle payer in exact calculation', () => { + const participants = createParticipants([user1, user2, user3], [6000n, -3000n, -3000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares, user1); + + // user1 is payer: paid 10000n but their amount is 6000n, so they owe 4000n + expect(splitShares[user1.id]![SplitType.EXACT]).toBe(4000n); + expect(splitShares[user2.id]![SplitType.EXACT]).toBe(3000n); + expect(splitShares[user3.id]![SplitType.EXACT]).toBe(3000n); + }); + + it('should handle zero amounts', () => { + const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EXACT, splitShares); + + expect(splitShares[user1.id]![SplitType.EXACT]).toBe(0n); + expect(splitShares[user2.id]![SplitType.EXACT]).toBe(0n); + expect(splitShares[user3.id]![SplitType.EXACT]).toBe(0n); + }); + }); + + describe('SplitType.ADJUSTMENT', () => { + it('should handle payer in adjustment calculation', () => { + const participants = createParticipants([user1, user2, user3], [-9500n, -4500n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 15000n, + participants, + SplitType.ADJUSTMENT, + splitShares, + user1, + ); + + expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(1000n); + expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); + expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(500n); + }); + + it('should handle participants with zero amounts', () => { + const participants = createParticipants([user1, user2, user3], [0n, -5000n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(5000n, participants, SplitType.ADJUSTMENT, splitShares); + + // Only user2 has non-zero amount, so equal share = 5000n / 1 = 5000n + // user2 owes 5000n vs 5000n = 0n adjustment + expect(splitShares[user1.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n + expect(splitShares[user2.id]![SplitType.ADJUSTMENT]).toBe(0n); // 5000n - 5000n + expect(splitShares[user3.id]![SplitType.ADJUSTMENT]).toBe(-5000n); // 0 - 5000n + }); + }); + + describe('Edge cases', () => { + it('should handle empty participants array', () => { + const participants: Participant[] = []; + const splitShares: Record> = {}; + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares); + + // Should not throw an error and splitShares should remain empty + expect(Object.keys(splitShares)).toHaveLength(0); + }); + + it('should handle when one participant owes entire amount', () => { + const participants = createParticipants([user1, user2], [10000n, -10000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); + + expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(0n); + expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(1n); + }); + + it('should handle self-payment (no money flow) scenario', () => { + const participants = createParticipants([user1, user2, user3], [0n, 0n, 0n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount(10000n, participants, SplitType.EQUAL, splitShares, user1); + + expect(splitShares[user1.id]![SplitType.EQUAL]).toBe(1n); + expect(splitShares[user2.id]![SplitType.EQUAL]).toBe(0n); + expect(splitShares[user3.id]![SplitType.EQUAL]).toBe(0n); + }); + + it('should handle undefined paidBy', () => { + const participants = createParticipants([user1, user2], [-5000n, -5000n]); + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 10000n, + participants, + SplitType.PERCENTAGE, + splitShares, + undefined, + ); + + expect(splitShares[user1.id]![SplitType.PERCENTAGE]).toBe(5000n); + expect(splitShares[user2.id]![SplitType.PERCENTAGE]).toBe(5000n); + }); + + it('should handle all split types with same data', () => { + const participants = createParticipants([user1, user2], [-6000n, -4000n]); + + Object.values(SplitType).forEach((splitType) => { + if (splitType === SplitType.SETTLEMENT) { + return; + } // Skip settlement as it's not implemented + + const splitShares: Record> = {}; + participants.forEach((p) => { + splitShares[p.id] = initSplitShares(); + }); + + // Should not throw for any split type + expect(() => { + calculateSplitShareBasedOnAmount(10000n, participants, splitType, splitShares); + }).not.toThrow(); + }); + }); + }); +}); + +// Integration tests for function reversibility +describe('Function Reversibility Tests', () => { + describe('calculateParticipantSplit -> calculateSplitShareBasedOnAmount', () => { + it('should properly reverse EQUAL split', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [1n, 1n, 1n]; + const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 15000n, + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 15000n, + splitResult.participants, + SplitType.EQUAL, + reversedShares, + user1, + ); + + // Check if we got back the original shares + expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); + }); + + it('should properly reverse PERCENTAGE split', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [5000n, 3000n, 2000n]; // 50%, 30%, 20% + const splitShares = createSplitShares(participants, SplitType.PERCENTAGE, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 20000n, + participants, + SplitType.PERCENTAGE, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 20000n, + splitResult.participants, + SplitType.PERCENTAGE, + reversedShares, + user1, + ); + + // Check if we got back the original percentage shares + expect(reversedShares[user1.id]![SplitType.PERCENTAGE]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.PERCENTAGE]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.PERCENTAGE]).toBe(originalShares[2]); + }); + + it('should properly reverse SHARE split', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [400n, 200n, 200n]; // 2:1:1 ratio + const splitShares = createSplitShares(participants, SplitType.SHARE, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 16000n, + participants, + SplitType.SHARE, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 16000n, + splitResult.participants, + SplitType.SHARE, + reversedShares, + user1, + ); + + // Check if we got back proportional shares (should maintain the 2:1:1 ratio) + const ratio1 = Number(reversedShares[user1.id]![SplitType.SHARE]) / Number(originalShares[0]); + const ratio2 = Number(reversedShares[user2.id]![SplitType.SHARE]) / Number(originalShares[1]); + const ratio3 = Number(reversedShares[user3.id]![SplitType.SHARE]) / Number(originalShares[2]); + + // All ratios should be approximately equal (accounting for rounding) + expect(Math.abs(ratio1 - ratio2)).toBeLessThan(0.1); + expect(Math.abs(ratio2 - ratio3)).toBeLessThan(0.1); + }); + + it('should properly reverse EXACT split', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [6000n, 4000n, 2000n]; + const splitShares = createSplitShares(participants, SplitType.EXACT, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 12000n, + participants, + SplitType.EXACT, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 12000n, + splitResult.participants, + SplitType.EXACT, + reversedShares, + user1, + ); + + // Check if we got back the original exact amounts + expect(reversedShares[user1.id]![SplitType.EXACT]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.EXACT]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.EXACT]).toBe(originalShares[2]); + }); + + it('should handle ADJUSTMENT split reversal (known to have bugs)', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [1000n, 0n, 500n]; // Various adjustments + const splitShares = createSplitShares(participants, SplitType.ADJUSTMENT, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 12300n, + participants, + SplitType.ADJUSTMENT, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 12300n, + splitResult.participants, + SplitType.ADJUSTMENT, + reversedShares, + user1, + ); + + expect(reversedShares[user1.id]![SplitType.ADJUSTMENT]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.ADJUSTMENT]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.ADJUSTMENT]).toBe(originalShares[2]); + }); + + it('should handle edge case with zero amounts', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [1n, 1n, 0n]; // One participant excluded + const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); + + // Apply split calculation + const splitResult = calculateParticipantSplit( + 10000n, + participants, + SplitType.EQUAL, + splitShares, + user1, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 10000n, + splitResult.participants, + SplitType.EQUAL, + reversedShares, + user1, + ); + + // Check if we preserved the zero share + expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(originalShares[0]); + expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(originalShares[1]); + expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(originalShares[2]); + }); + + it('should handle external payer scenario', () => { + const participants = createParticipants([user1, user2, user3]); + const originalShares = [1n, 1n, 1n]; + const splitShares = createSplitShares(participants, SplitType.EQUAL, originalShares); + const externalPayer = createMockUser(4, 'External', 'external@example.com'); + + // Apply split calculation with external payer + const splitResult = calculateParticipantSplit( + 15000n, + participants, + SplitType.EQUAL, + splitShares, + externalPayer, + ); + + // Reverse the calculation + const reversedShares: Record> = {}; + participants.forEach((p) => { + reversedShares[p.id] = initSplitShares(); + }); + + calculateSplitShareBasedOnAmount( + 15000n, + splitResult.participants, + SplitType.EQUAL, + reversedShares, + externalPayer, + ); + + // With external payer, all participants have 0 amounts due to balance adjustment + // So reversed shares should all be 0 + expect(reversedShares[user1.id]![SplitType.EQUAL]).toBe(0n); + expect(reversedShares[user2.id]![SplitType.EQUAL]).toBe(0n); + expect(reversedShares[user3.id]![SplitType.EQUAL]).toBe(0n); + }); + }); +}); + +describe('Penny distribution preservation', () => { + beforeEach(() => { + useAddExpenseStore.getState().actions.resetState(); + }); + + it('should preserve different penny distributions when editing expenses', () => { + const store = useAddExpenseStore.getState(); + + store.actions.setCurrentUser(user1); + store.actions.setPaidBy(user1); + store.actions.setAmount(4n); + + const distributionA: Participant[] = [ + { ...user1, amount: 3n }, + { ...user2, amount: -2n }, + { ...user3, amount: -1n }, + ]; + + store.actions.setParticipants(distributionA, SplitType.EQUAL); + const resultA = useAddExpenseStore.getState().participants; + + store.actions.resetState(); + store.actions.setCurrentUser(user1); + store.actions.setPaidBy(user1); + store.actions.setAmount(4n); + + const distributionB: Participant[] = [ + { ...user1, amount: 2n }, + { ...user2, amount: -1n }, + { ...user3, amount: -1n }, + ]; + + store.actions.setParticipants(distributionB, SplitType.EQUAL); + const resultB = useAddExpenseStore.getState().participants; + + expect(resultA[0]!.amount).toBe(3n); + expect(resultA[1]!.amount).toBe(-2n); + expect(resultA[2]!.amount).toBe(-1n); + + expect(resultB[0]!.amount).toBe(2n); + expect(resultB[1]!.amount).toBe(-1n); + expect(resultB[2]!.amount).toBe(-1n); + + expect(resultA[0]!.amount).not.toBe(resultB[0]!.amount); + }); + + it('should maintain amounts across multiple edits', () => { + const store = useAddExpenseStore.getState(); + + store.actions.setCurrentUser(user1); + store.actions.setPaidBy(user1); + store.actions.setAmount(4n); + + const edit1: Participant[] = [ + { ...user1, amount: 3n }, + { ...user2, amount: -2n }, + { ...user3, amount: -1n }, + ]; + store.actions.setParticipants(edit1, SplitType.EQUAL); + let result = useAddExpenseStore.getState().participants; + expect(result[0]!.amount).toBe(3n); + expect(result[1]!.amount).toBe(-2n); + + const edit2: Participant[] = [ + { ...user1, amount: 2n }, + { ...user2, amount: -1n }, + { ...user3, amount: -1n }, + ]; + store.actions.setParticipants(edit2, SplitType.EQUAL); + result = useAddExpenseStore.getState().participants; + expect(result[0]!.amount).toBe(2n); + expect(result[1]!.amount).toBe(-1n); + + store.actions.setParticipants(edit1, SplitType.EQUAL); + result = useAddExpenseStore.getState().participants; + expect(result[0]!.amount).toBe(3n); + expect(result[1]!.amount).toBe(-2n); + }); +});