|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
|
import { db } from '../db/database';
|
|
|
|
|
import { BudgetItem, BudgetItemMember } from '../types';
|
|
|
|
|
import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types';
|
|
|
|
|
import { avatarUrl } from './avatarUrl';
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@@ -19,6 +19,30 @@ function loadItemMembers(itemId: number | string) {
|
|
|
|
|
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
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@@ -50,20 +74,45 @@ export function listBudgetItems(tripId: string | number) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items.forEach(item => { item.members = membersByItem[item.id] || []; });
|
|
|
|
|
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; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
|
|
|
|
|
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';
|
|
|
|
|
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);
|
|
|
|
@@ -73,22 +122,37 @@ export function createBudgetItem(
|
|
|
|
|
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, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
|
|
|
|
'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,
|
|
|
|
|
data.total_price || 0,
|
|
|
|
|
data.persons != null ? data.persons : null,
|
|
|
|
|
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 item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] };
|
|
|
|
|
item.members = [];
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -106,7 +170,12 @@ export function linkBudgetItemToReservation(
|
|
|
|
|
export function updateBudgetItem(
|
|
|
|
|
id: string | number,
|
|
|
|
|
tripId: string | number,
|
|
|
|
|
data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null },
|
|
|
|
|
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;
|
|
|
|
@@ -116,6 +185,8 @@ export function updateBudgetItem(
|
|
|
|
|
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,
|
|
|
|
@@ -126,6 +197,8 @@ export function updateBudgetItem(
|
|
|
|
|
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,
|
|
|
|
@@ -134,6 +207,15 @@ export function updateBudgetItem(
|
|
|
|
|
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);
|
|
|
|
@@ -144,8 +226,23 @@ export function updateBudgetItem(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -220,37 +317,65 @@ export function getPerPersonSummary(tripId: string | number) {
|
|
|
|
|
// Settlement calculation (greedy debt matching)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function calculateSettlement(tripId: string | number) {
|
|
|
|
|
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, bm.paid, u.username, u.avatar
|
|
|
|
|
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 })[];
|
|
|
|
|
|
|
|
|
|
// Calculate net balance per user: positive = is owed money, negative = owes money
|
|
|
|
|
// 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);
|
|
|
|
|
if (members.length === 0) continue;
|
|
|
|
|
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
|
|
|
|
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
|
|
|
|
|
|
|
|
|
const payers = members.filter(m => m.paid);
|
|
|
|
|
if (payers.length === 0) continue; // no one marked as paid
|
|
|
|
|
const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency), 0);
|
|
|
|
|
const sharePerMember = paidBase / members.length;
|
|
|
|
|
|
|
|
|
|
const sharePerMember = item.total_price / members.length;
|
|
|
|
|
const paidPerPayer = item.total_price / payers.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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const m of members) {
|
|
|
|
|
if (!balances[m.user_id]) {
|
|
|
|
|
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
|
|
|
|
|
}
|
|
|
|
|
// Everyone owes their share
|
|
|
|
|
balances[m.user_id].balance -= sharePerMember;
|
|
|
|
|
// Payers get credited what they paid
|
|
|
|
|
if (m.paid) balances[m.user_id].balance += paidPerPayer;
|
|
|
|
|
}
|
|
|
|
|
// 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)
|
|
|
|
@@ -283,9 +408,52 @@ export function calculateSettlement(tripId: string | number) {
|
|
|
|
|
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
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|