File size: 7,684 Bytes
1f5ea39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
import { describe, it, expect, vi, beforeEach } from 'vitest';

// ── DB mock setup ────────────────────────────────────────────────────────────

const mockDb = vi.hoisted(() => {
  return {
    db: {
      prepare: vi.fn(() => ({
        all: vi.fn(() => []),
        get: vi.fn(() => undefined),
        run: vi.fn(),
      })),
    },
    canAccessTrip: vi.fn(() => true),
  };
});

vi.mock('../../../src/db/database', () => mockDb);

import { calculateSettlement } from '../../../src/services/budgetService';
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';

// ── Helpers ──────────────────────────────────────────────────────────────────
// Who actually paid is recorded as explicit payers (budget_item_payers); members
// are only the equal-split participants.

function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem {
  return { id, trip_id, name: `Item ${id}`, total_price, category: 'other' } as BudgetItem;
}

function makeMember(budget_item_id: number, user_id: number, username: string): BudgetItemMember & { budget_item_id: number } {
  return { budget_item_id, user_id, paid: 0, username, avatar: null } as BudgetItemMember & { budget_item_id: number };
}

function makePayer(budget_item_id: number, user_id: number, amount: number, username: string): BudgetItemPayer & { budget_item_id: number } {
  return { budget_item_id, user_id, amount, username, avatar: null } as BudgetItemPayer & { budget_item_id: number };
}

function setupDb(
  items: BudgetItem[],
  members: (BudgetItemMember & { budget_item_id: number })[],
  payers: (BudgetItemPayer & { budget_item_id: number })[] = [],
) {
  mockDb.db.prepare.mockImplementation((sql: string) => {
    if (sql.includes('SELECT * FROM budget_items')) {
      return { all: vi.fn(() => items), get: vi.fn(), run: vi.fn() };
    }
    if (sql.includes('budget_item_members')) {
      return { all: vi.fn(() => members), get: vi.fn(), run: vi.fn() };
    }
    if (sql.includes('budget_item_payers')) {
      return { all: vi.fn(() => payers), get: vi.fn(), run: vi.fn() };
    }
    // budget_settlements and anything else β†’ empty
    return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
  });
}

beforeEach(() => {
  vi.clearAllMocks();
  setupDb([], [], []);
});

// ── calculateSettlement ──────────────────────────────────────────────────────

describe('calculateSettlement', () => {
  it('returns empty balances and flows when trip has no items', () => {
    setupDb([], [], []);
    const result = calculateSettlement(1);
    expect(result.balances).toEqual([]);
    expect(result.flows).toEqual([]);
  });

  it('returns no flows when there are items but no members', () => {
    setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]);
    const result = calculateSettlement(1);
    expect(result.flows).toEqual([]);
  });

  it('returns no flows when no one has paid', () => {
    setupDb(
      [makeItem(1, 100)],
      [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
      [],
    );
    const result = calculateSettlement(1);
    expect(result.flows).toEqual([]);
  });

  it('2 members, 1 payer: payer is owed half, non-payer owes half', () => {
    // Item: $100. Alice paid all, [Alice, Bob] split. Each owes $50. Alice net: +$50. Bob: -$50.
    setupDb(
      [makeItem(1, 100)],
      [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
      [makePayer(1, 1, 100, 'alice')],
    );
    const result = calculateSettlement(1);
    const alice = result.balances.find(b => b.user_id === 1)!;
    const bob = result.balances.find(b => b.user_id === 2)!;
    expect(alice.balance).toBe(50);
    expect(bob.balance).toBe(-50);
    expect(result.flows).toHaveLength(1);
    expect(result.flows[0].from.user_id).toBe(2); // Bob owes
    expect(result.flows[0].to.user_id).toBe(1);   // Alice is owed
    expect(result.flows[0].amount).toBe(50);
  });

  it('3 members, 1 payer: correct 3-way split', () => {
    // Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30.
    setupDb(
      [makeItem(1, 90)],
      [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
      [makePayer(1, 1, 90, 'alice')],
    );
    const result = calculateSettlement(1);
    const alice = result.balances.find(b => b.user_id === 1)!;
    const bob = result.balances.find(b => b.user_id === 2)!;
    const carol = result.balances.find(b => b.user_id === 3)!;
    expect(alice.balance).toBe(60);
    expect(bob.balance).toBe(-30);
    expect(carol.balance).toBe(-30);
    expect(result.flows).toHaveLength(2);
  });

  it('all paid equally: all balances are zero, no flows', () => {
    // Item: $60. 3 members, each paid $20 and owes $20. Net: 0 for everyone.
    setupDb(
      [makeItem(1, 60)],
      [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
      [makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')],
    );
    const result = calculateSettlement(1);
    for (const b of result.balances) {
      expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
    }
    expect(result.flows).toHaveLength(0);
  });

  it('flow direction: from is debtor (owes), to is creditor (is owed)', () => {
    // Alice paid $100 for 2 people. Bob owes Alice $50.
    setupDb(
      [makeItem(1, 100)],
      [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
      [makePayer(1, 1, 100, 'alice')],
    );
    const result = calculateSettlement(1);
    const flow = result.flows[0];
    expect(flow.from.username).toBe('bob');   // debtor
    expect(flow.to.username).toBe('alice');   // creditor
  });

  it('amounts are rounded to 2 decimal places', () => {
    // Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33.
    setupDb(
      [makeItem(1, 10)],
      [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
      [makePayer(1, 1, 10, 'alice')],
    );
    const result = calculateSettlement(1);
    for (const b of result.balances) {
      const str = b.balance.toString();
      const decimals = str.includes('.') ? str.split('.')[1].length : 0;
      expect(decimals).toBeLessThanOrEqual(2);
    }
    for (const flow of result.flows) {
      const str = flow.amount.toString();
      const decimals = str.includes('.') ? str.split('.')[1].length : 0;
      expect(decimals).toBeLessThanOrEqual(2);
    }
  });

  it('2 items with different payers: aggregates balances correctly', () => {
    // Item 1: $100, Alice paid, [Alice, Bob] (Alice net: +50, Bob: -50)
    // Item 2: $60, Bob paid, [Alice, Bob] (Bob net: +30, Alice: -30)
    // Final: Alice: +50 - 30 = +20, Bob: -50 + 30 = -20
    setupDb(
      [makeItem(1, 100), makeItem(2, 60)],
      [
        makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'),
        makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob'),
      ],
      [makePayer(1, 1, 100, 'alice'), makePayer(2, 2, 60, 'bob')],
    );
    const result = calculateSettlement(1);
    const alice = result.balances.find(b => b.user_id === 1)!;
    const bob = result.balances.find(b => b.user_id === 2)!;
    expect(alice.balance).toBe(20);
    expect(bob.balance).toBe(-20);
    expect(result.flows).toHaveLength(1);
    expect(result.flows[0].amount).toBe(20);
  });
});