File size: 22,502 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
import { db } from '../db/database';
import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types';
import { avatarUrl } from './avatarUrl';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

export { avatarUrl };
export { verifyTripAccess } from './tripAccess';

function loadItemMembers(itemId: number | string) {
  const rows = db.prepare(`
    SELECT bm.user_id, bm.paid, u.username, u.avatar
    FROM budget_item_members bm
    JOIN users u ON bm.user_id = u.id
    WHERE bm.budget_item_id = ?
  `).all(itemId) as BudgetItemMember[];
  return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
}

function loadItemPayers(itemId: number | string) {
  const rows = db.prepare(`
    SELECT bp.user_id, bp.amount, u.username, u.avatar
    FROM budget_item_payers bp
    JOIN users u ON bp.user_id = u.id
    WHERE bp.budget_item_id = ?
  `).all(itemId) as BudgetItemPayer[];
  return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) }));
}

/** Replace the payer rows of an item and keep total_price = sum of payer amounts. */
function writeItemPayers(itemId: number | string, payers: { user_id: number; amount: number }[]) {
  db.prepare('DELETE FROM budget_item_payers WHERE budget_item_id = ?').run(itemId);
  const insert = db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)');
  let total = 0;
  for (const p of payers) {
    if (!(p.amount > 0)) continue;
    insert.run(itemId, p.user_id, p.amount);
    total += p.amount;
  }
  db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(total, itemId);
  return total;
}

// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------

export function listBudgetItems(tripId: string | number) {
  const items = db.prepare(`
    SELECT bi.* FROM budget_items bi
    LEFT JOIN budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = bi.category
    WHERE bi.trip_id = ?
    ORDER BY COALESCE(bco.sort_order, 999999) ASC, bi.sort_order ASC
  `).all(tripId) as BudgetItem[];

  const itemIds = items.map(i => i.id);
  const membersByItem: Record<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};

  if (itemIds.length > 0) {
    const allMembers = db.prepare(`
      SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
      FROM budget_item_members bm
      JOIN users u ON bm.user_id = u.id
      WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
    `).all(...itemIds) as (BudgetItemMember & { budget_item_id: number })[];

    for (const m of allMembers) {
      if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
      membersByItem[m.budget_item_id].push({
        user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m),
      });
    }
  }

  const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {};
  if (itemIds.length > 0) {
    const allPayers = db.prepare(`
      SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
      FROM budget_item_payers bp
      JOIN users u ON bp.user_id = u.id
      WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')})
    `).all(...itemIds) as (BudgetItemPayer & { budget_item_id: number })[];

    for (const p of allPayers) {
      if (!payersByItem[p.budget_item_id]) payersByItem[p.budget_item_id] = [];
      payersByItem[p.budget_item_id].push({
        user_id: p.user_id, amount: p.amount, username: p.username, avatar_url: avatarUrl(p),
      });
    }
  }

  items.forEach(item => {
    item.members = membersByItem[item.id] || [];
    item.payers = payersByItem[item.id] || [];
  });
  return items;
}

export function createBudgetItem(
  tripId: string | number,
  data: {
    category?: string; name: string; total_price?: number;
    currency?: string | null; exchange_rate?: number;
    payers?: { user_id: number; amount: number }[]; member_ids?: number[];
    persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
  },
) {
  const maxOrder = db.prepare(
    'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
  ).get(tripId) as { max: number | null };
  const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;

  const cat = data.category || 'other';

  // Ensure category has a sort_order entry
  const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
  if (!catExists) {
    const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
    const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
    db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
  }

  // total_price is derived from explicit payers when given; otherwise the caller
  // value (planning entries, or a bill no one has paid yet).
  const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0);
  const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);

  const result = db.prepare(
    'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
  ).run(
    tripId,
    cat,
    data.name,
    total,
    data.currency || null,
    data.exchange_rate != null ? data.exchange_rate : 1,
    data.member_ids ? data.member_ids.length : (data.persons != null ? data.persons : null),
    data.days !== undefined && data.days !== null ? data.days : null,
    data.note || null,
    sortOrder,
    data.expense_date || null,
  );

  const itemId = result.lastInsertRowid as number;
  if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
  if (data.member_ids && data.member_ids.length > 0) {
    const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
    for (const uid of data.member_ids) insert.run(itemId, uid);
  }

  const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId) as BudgetItem;
  item.members = loadItemMembers(itemId);
  item.payers = loadItemPayers(itemId);
  return item;
}

export function linkBudgetItemToReservation(
  tripId: string | number,
  reservationId: number,
  data: { name: string; category?: string; total_price: number },
) {
  const item = createBudgetItem(tripId, data) as BudgetItem & { reservation_id?: number | null };
  db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id);
  item.reservation_id = reservationId;
  return item;
}

export function updateBudgetItem(
  id: string | number,
  tripId: string | number,
  data: {
    category?: string; name?: string; total_price?: number;
    currency?: string | null; exchange_rate?: number;
    payers?: { user_id: number; amount: number }[]; member_ids?: number[];
    persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
  },
) {
  const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
  if (!item) return null;

  db.prepare(`
    UPDATE budget_items SET
      category = COALESCE(?, category),
      name = COALESCE(?, name),
      total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
      currency = CASE WHEN ? THEN ? ELSE currency END,
      exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END,
      persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
      days = CASE WHEN ? THEN ? ELSE days END,
      note = CASE WHEN ? THEN ? ELSE note END,
      sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END,
      expense_date = CASE WHEN ? THEN ? ELSE expense_date END
    WHERE id = ?
  `).run(
    data.category || null,
    data.name || null,
    data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
    data.currency !== undefined ? 1 : 0, data.currency !== undefined ? (data.currency || null) : null,
    data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
    data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
    data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
    data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
    data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0,
    data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null,
    id,
  );

  // Optional inline payer/member replacement (the edit modal saves all at once).
  if (data.payers !== undefined) writeItemPayers(id, data.payers);
  if (data.member_ids !== undefined) {
    db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
    const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
    for (const uid of data.member_ids) insert.run(id, uid);
    db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id);
  }

  // If category changed, update category order table
  if (data.category) {
    const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
    if (!catExists) {
      const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
      const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
      db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, data.category, catOrder);
    }
  }

  const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
  updated.members = loadItemMembers(id);
  updated.payers = loadItemPayers(id);
  return updated;
}

// ---------------------------------------------------------------------------
// Payers
// ---------------------------------------------------------------------------

export function setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) {
  const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
  if (!item) return null;
  writeItemPayers(id, payers);
  const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
  updated.members = loadItemMembers(id);
  updated.payers = loadItemPayers(id);
  return updated;
}

export function deleteBudgetItem(id: string | number, tripId: string | number): boolean {
  const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
  if (!item) return false;
  db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
  return true;
}

// ---------------------------------------------------------------------------
// Members
// ---------------------------------------------------------------------------

export function updateMembers(id: string | number, tripId: string | number, userIds: number[]) {
  const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
  if (!item) return null;

  const existingPaid: Record<number, number> = {};
  const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id) as { user_id: number; paid: number }[];
  for (const e of existing) existingPaid[e.user_id] = e.paid;

  db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);

  if (userIds.length > 0) {
    const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
    for (const userId of userIds) insert.run(id, userId, existingPaid[userId] || 0);
    db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(userIds.length, id);
  } else {
    db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
  }

  const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
  const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
  return { members, item: updated };
}

export function toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) {
  // Resolve the item within the caller's trip before updating.
  const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
  if (!item) return null;

  db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
    .run(paid ? 1 : 0, id, userId);

  const member = db.prepare(`
    SELECT bm.user_id, bm.paid, u.username, u.avatar
    FROM budget_item_members bm JOIN users u ON bm.user_id = u.id
    WHERE bm.budget_item_id = ? AND bm.user_id = ?
  `).get(id, userId) as BudgetItemMember | undefined;

  return member ? { ...member, avatar_url: avatarUrl(member) } : null;
}

// ---------------------------------------------------------------------------
// Per-person summary
// ---------------------------------------------------------------------------

export function getPerPersonSummary(tripId: string | number) {
  const summary = db.prepare(`
    SELECT bm.user_id, u.username, u.avatar,
      SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned,
      SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid,
      COUNT(bi.id) as items_count
    FROM budget_item_members bm
    JOIN budget_items bi ON bm.budget_item_id = bi.id
    JOIN users u ON bm.user_id = u.id
    WHERE bi.trip_id = ?
    GROUP BY bm.user_id
  `).all(tripId) as { user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }[];

  return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
}

// ---------------------------------------------------------------------------
// Settlement calculation (greedy debt matching)
// ---------------------------------------------------------------------------

export function calculateSettlement(
  tripId: string | number,
  opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string } = {},
) {
  const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
  const tripCurrency = (opts.tripCurrency || base).toUpperCase();
  const rates = opts.rates ?? null;
  // Amount in some currency → base. Pre-rework rows store currency = NULL, which
  // means "the trip's own currency". rates[X] = units of X per 1 base.
  const toBase = (amount: number, itemCurrency: string | null | undefined): number => {
    const cur = (itemCurrency || tripCurrency).toUpperCase();
    if (cur === base || !rates) return amount;
    const r = rates[cur];
    return r && r > 0 ? amount / r : amount;
  };

  const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
  const allMembers = db.prepare(`
    SELECT bm.budget_item_id, bm.user_id, u.username, u.avatar
    FROM budget_item_members bm
    JOIN users u ON bm.user_id = u.id
    WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
  `).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
  const allPayers = db.prepare(`
    SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
    FROM budget_item_payers bp
    JOIN users u ON bp.user_id = u.id
    WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
  `).all(tripId) as (BudgetItemPayer & { budget_item_id: number })[];

  // Net balance per user, in the requested base currency: positive = is owed
  // money, negative = owes money. Each expense's amounts are converted from their
  // own currency to the base with live rates, so mixed-currency trips net correctly.
  const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
  const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
    if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), balance: 0 };
    return balances[id];
  };

  for (const item of items) {
    const members = allMembers.filter(m => m.budget_item_id === item.id);
    const payers = allPayers.filter(p => p.budget_item_id === item.id);
    if (members.length === 0) continue; // planning-only entry → doesn't affect balances

    const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency), 0);
    const sharePerMember = paidBase / members.length;

    // Payers are credited what they actually paid (converted to base)…
    for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency);
    // …and every split participant owes an equal share of the base total.
    for (const m of members) ensure(m.user_id, m).balance -= sharePerMember;
  }

  // Persisted settle-up transfers already moved money: the payer's debt shrinks,
  // the receiver's credit shrinks, so the corresponding flow disappears.
  const settlements = listSettlements(tripId);
  for (const s of settlements) {
    if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
    if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
  }

  // Calculate optimized payment flows (greedy algorithm)
  const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
  const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
  const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));

  // Sort by amount descending for efficient matching
  debtors.sort((a, b) => b.amount - a.amount);
  creditors.sort((a, b) => b.amount - a.amount);

  const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];

  let di = 0, ci = 0;
  while (di < debtors.length && ci < creditors.length) {
    const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
    if (transfer > 0.01) {
      flows.push({
        from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
        to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
        amount: Math.round(transfer * 100) / 100,
      });
    }
    debtors[di].amount -= transfer;
    creditors[ci].amount -= transfer;
    if (debtors[di].amount < 0.01) di++;
    if (creditors[ci].amount < 0.01) ci++;
  }

  return {
    balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
    flows,
    settlements,
  };
}

// ---------------------------------------------------------------------------
// Settlements (persisted settle-up transfers — history + undo)
// ---------------------------------------------------------------------------

export function listSettlements(tripId: string | number) {
  const rows = db.prepare(`
    SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.created_at, s.created_by_user_id,
           fu.username AS from_username, fu.avatar AS from_avatar,
           tu.username AS to_username,   tu.avatar AS to_avatar
    FROM budget_settlements s
    JOIN users fu ON s.from_user_id = fu.id
    JOIN users tu ON s.to_user_id = tu.id
    WHERE s.trip_id = ?
    ORDER BY s.created_at DESC, s.id DESC
  `).all(tripId) as any[];
  return rows.map(r => ({
    id: r.id, trip_id: r.trip_id,
    from_user_id: r.from_user_id, to_user_id: r.to_user_id,
    amount: r.amount, created_at: r.created_at, created_by_user_id: r.created_by_user_id,
    from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
    to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
  }));
}

export function createSettlement(
  tripId: string | number,
  data: { from_user_id: number; to_user_id: number; amount: number },
  createdByUserId?: number,
) {
  const result = db.prepare(
    'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, created_by_user_id) VALUES (?, ?, ?, ?, ?)'
  ).run(tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, createdByUserId ?? null);
  return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
}

export function deleteSettlement(id: string | number, tripId: string | number): boolean {
  const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
  if (!row) return false;
  db.prepare('DELETE FROM budget_settlements WHERE id = ?').run(id);
  return true;
}

// ---------------------------------------------------------------------------
// Reorder
// ---------------------------------------------------------------------------

export function reorderBudgetItems(tripId: string | number, orderedIds: number[]) {
  const update = db.prepare('UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
  db.transaction(() => {
    orderedIds.forEach((id, index) => update.run(index, id, tripId));
  })();
}

export function reorderBudgetCategories(tripId: string | number, orderedCategories: string[]) {
  const upsert = db.prepare(
    'INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?) ON CONFLICT(trip_id, category) DO UPDATE SET sort_order = excluded.sort_order'
  );
  db.transaction(() => {
    orderedCategories.forEach((cat, index) => upsert.run(tripId, cat, index));
  })();
}