diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index d6dba4cd1..0d4fa44b2 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -2,30 +2,31 @@ import { Fragment, useState, useEffect, useMemo, useCallback, useId, type ReactN import { useSearchParams } from 'react-router' import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip, ScanLine, Users } from 'lucide-react' import { useTripStore } from '../../store/tripStore' -import { useAuthStore } from '../../store/authStore' -import { useSettingsStore } from '../../store/settingsStore' import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' -import { budgetApi } from '../../api/client' -import { convertBooked, convertedLine, tripAmountOf, useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates' import { splitShareLabel } from './expenseFx' -import { useFreezeMissingRates } from './useFreezeMissingRates' import { useIsMobile } from '../../hooks/useIsMobile' -import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' -import { downloadBlob, openFile } from '../../utils/fileDownload' +import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters' +import { openFile } from '../../utils/fileDownload' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' -import { localToday } from '../Planner/today' import { useReceiptScan } from './useReceiptScan' import { ReceiptScanModal } from './ReceiptScanModal' -import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' -import { finalBudgetFor, finalBudgetSources, NOTE_MAX, paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers' -import { COST_CATEGORY_LIST, catMeta } from './costsCategories' +import { SYMBOLS, SPLIT_COLORS } from './BudgetPanel.constants' +import { finalBudgetFor, finalBudgetSources, NOTE_MAX, paidByUser, readUserNote, settlementDate } from './CostsPanel.helpers' +import { COST_CAT_META, COST_CATEGORY_LIST, catMeta } from './costsCategories' +import { + baseTotal as baseTotalOf, booked as bookedAmount, currencyOptions, isUnfinished as isUnfinishedModel, lineOf as lineOfModel, memberShareOf, + myPaidOf as myPaidOfModel, paymentAmount, paymentLineOf, settledShareOf, + type CostsSettlement as Settlement, type CostsSettlementResponse, +} from './costsModel' +import { useCostsLedger } from './useCostsLedger' +import { useSettlementForm } from './useSettlementForm' import { usePercentSplit, type CustomSplitUnit } from './usePercentSplit' import { useExpenseForm } from './useExpenseForm' import { ReceiptPreviewModal } from './ReceiptPreviewModal' -import type { BudgetParticipantFinal, BudgetUnconverted, ReceiptLine } from '@trek/shared' +import type { BudgetParticipantFinal, ReceiptLine } from '@trek/shared' import type { BudgetItem, BudgetItemReceipt } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' import GuestBadge from '../shared/GuestBadge' @@ -46,41 +47,6 @@ interface CostsPanelProps { tripMembers?: TripMember[] } -interface Settlement { - id: number - from_user_id: number - to_user_id: number - amount: number - // The currency the transfer was entered in. Legacy rows predate it (null) and are - // read as the display currency, which is what the server assumes for them too. - currency?: string | null - // The rate frozen when the transfer was settled, in units of `currency` per 1 trip - // currency (#1445). Absent, or exactly 1, on rows written before the freeze existed. - exchange_rate?: number - created_at?: string - // The day the transfer actually happened; editable, unlike created_at (when it - // was recorded). Null/absent on rows predating this field — settlementDate() - // falls back to created_at for those. - settled_at?: string | null - /** A free-text note on the payment (#2340). */ - note?: string | null - from_username?: string - to_username?: string -} -interface SettlementData { - balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[] - flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[] - settlements: Settlement[] - // What the trip ends up costing each participant. Computed server-side off the - // same ledger as the balances, so the breakdown can't contradict them. - finalBudgets: BudgetParticipantFinal[] - // The currency the figures are in: the display currency, or the trip's own when - // neither the server nor `base_rate` could quote the pair. - currency?: string - // Rows no rate could convert, left out of every figure above. - unconverted?: BudgetUnconverted -} - // One row in the unified Costs ledger — either an expense or a settle-up payment, // carrying the date used to group it by day. type LedgerEntry = @@ -93,7 +59,6 @@ const COSTS_VIEW_KEY = 'trek:costs-view' export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps) { const { trip, budgetItems, deleteBudgetItem, loadBudgetItems, addBudgetItem, updateBudgetItem } = useTripStore() - const me = useAuthStore(s => s.user?.id ?? -1) const can = useCanDo() const canEdit = can('budget_edit', trip) const receiptScan = useReceiptScan(tripId, canEdit) @@ -101,23 +66,18 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const { t, locale } = useTranslation() const isMobile = useIsMobile() - // Display/base currency = the user's preferred currency (Settings), falling back - // to the trip's own currency. Everything in Costs is converted to and shown in it. - const displayCurrency = useSettingsStore(s => s.settings.default_currency) - const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() - // Pre-rework rows stored currency = NULL, meaning "the trip's own currency". - const tripCurrency = (trip?.currency || base).toUpperCase() - // Anchored on the trip currency's quote, the one the server books with (#2525). - const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency) + // Display/base currency, the trip's own, the settlement, totals, filters and + // the ledger writes: the same ledger the phone tab runs on. + const { + me, base, tripCurrency, ctx, settlement, settlementError, loadSettlement, + search, setSearch, segment: filter, setSegment: setFilter, catFilter, setCatFilter, dayFilter, setDayFilter, + totals, filtered, filteredSettlements, catBreakdown, exportCsv: handleExportCsv, + deleteExpense: handleDelete, undoSettlement, settleFlow, settleAll, + } = useCostsLedger({ + tripId, trip, budgetItems, actions: { loadBudgetItems, deleteBudgetItem }, canEdit, t, toast, + settlementOnTripChange: true, skipUnfinishedShares: true, csvCurrencyAsStored: true, + }) const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency]) - const [settlement, setSettlement] = useState(null) - // A failed settlement read leaves `settlement` null, which the empty views would - // otherwise present as "everyone is square", a balance claim we cannot make. - const [settlementError, setSettlementError] = useState(false) - const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all') - const [search, setSearch] = useState('') - const [catFilter, setCatFilter] = useState('') // '' = all categories - const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null) @@ -152,21 +112,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale]) const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale]) - // The browser's own figure for the display currency goes along, for the server to - // answer in it when it cannot fetch a quote itself. - const loadSettlement = useCallback(() => { - budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) - .then(s => { setSettlement(s); setSettlementError(false) }) - .catch(() => setSettlementError(true)) - }, [tripId, base, tripCurrency, displayPerTrip]) - - useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId]) - useEffect(() => { loadSettlement() }, [budgetItems.length, loadSettlement]) - - // Rows the server could not count get a rate frozen from the browser's, and the - // settlement is read again once they count. - useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement }) - // The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense. const [searchParams, setSearchParams] = useSearchParams() useEffect(() => { @@ -179,81 +124,23 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // ── derived expense maths (everything converted to the base currency) ──── // Booked, not live: an expense entered in a foreign currency keeps the rate it was // entered at, which is the same rule the server settles by (#1335). - const booked = useCallback( - (amount: number, e: BudgetItem) => convertBooked(amount, e.currency, e.exchange_rate, tripCurrency, convert), - [convert, tripCurrency], - ) - const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e) - // A transfer freezes its own rate at settle time, in its own table (#1445). One - // without a currency predates that and was entered in the display currency, which - // is how the server settles it too, not in the trip's. - const settled = useCallback( - (s: Settlement) => convertBooked(s.amount, s.currency || base, s.exchange_rate, tripCurrency, convert), - [convert, tripCurrency, base], - ) - const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), e) + const booked = useCallback((amount: number, e: BudgetItem) => bookedAmount(amount, e, ctx), [ctx]) + const baseTotal = (e: BudgetItem) => baseTotalOf(e, ctx) + // A transfer freezes its own rate at settle time, in its own table (#1445). + const settled = (s: Settlement) => paymentAmount(s, ctx) + const myPaidOf = (e: BudgetItem) => myPaidOfModel(e, ctx) // The line under an amount shown converted: what was entered, then where it went (#2525). - const lineOf = (amount: number, e: BudgetItem, shown: number) => - convertedLine(amount, e.currency, e.exchange_rate, tripCurrency, base, shown) - // "Unfinished": a recorded total nobody has paid yet — counts toward the trip - // total but stays out of settlements until who-paid is filled in. A negative - // total (a refund, #2176) is just as unfinished until its recipient is named. - const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0 + const lineOf = (amount: number, e: BudgetItem, shown: number) => lineOfModel(amount, e, ctx, shown) + // "Unfinished": a recorded total nobody has paid yet, counted toward the trip + // total but kept out of settlements until who-paid is filled in (#2176). + const isUnfinished = (e: BudgetItem) => isUnfinishedModel(e, ctx) // A member's part of an expense: the custom amount when one was set, else the // equal split the server settles with, in the display currency. - const shareOf = (e: BudgetItem, userId: number) => { - const member = (e.members || []).find(m => m.user_id === userId) - if (!member) return 0 - if (member.amount !== null && member.amount !== undefined) { - return booked(member.amount, e) - } - const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id) - return booked(shares[userId] || 0, e) - } - // Nobody paid, so nobody owes: the ledger skips these entirely (#2225), and - // counting them here left the tile contradicting the balances right beside it. - const myShareOf = (e: BudgetItem) => (isUnfinished(e) ? 0 : shareOf(e, me)) - - // `booked` carries the rates. They can land after the expenses, and without it in the - // deps the cards kept the sums they were first added up with while the rows moved on. - const totals = useMemo(() => { - const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) - const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0) - const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0) - const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0) - const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0) - const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0) - const outstandingCount = budgetItems.filter(isUnfinished).length - return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount } - }, [budgetItems, settlement, me, booked]) - - // ── filtering + day grouping ──────────────────────────────────────────── - const filtered = useMemo(() => { - let list = budgetItems.slice() - if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0) - if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0) - // catMeta normalises legacy/free-text categories to the fixed keys, so the - // filter matches rows saved before the category rework too. - if (catFilter) list = list.filter(e => catMeta(e.category).key === catFilter) - if (dayFilter) list = list.filter(e => (e.expense_date || '') === dayFilter) - const q = search.trim().toLowerCase() - if (q) list = list.filter(e => e.name.toLowerCase().includes(q)) - return list - }, [budgetItems, filter, search, catFilter, dayFilter, me, booked]) - - // Settlements ("payments") shown inline in the ledger. They have no name, so a - // text search hides them; they're excluded from the "owed" expense filter and, - // under "mine", only show transfers I'm part of. - const filteredSettlements = useMemo(() => { - // Payments carry no name or category, so a text/category filter hides them. - if (search.trim() || catFilter) return [] - if (filter === 'owed') return [] - let list = settlement?.settlements || [] - if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me) - if (dayFilter) list = list.filter(s => settlementDate(s) === dayFilter) - return list - }, [settlement, filter, search, catFilter, dayFilter, me]) + const shareOf = (e: BudgetItem, userId: number) => memberShareOf(e, userId, ctx) + // Nobody paid, so nobody owes (#2225). + const myShareOf = (e: BudgetItem) => settledShareOf(e, ctx) + // ── day grouping ───────────────────────────────────────────────────────── const dayGroups = useMemo(() => { const entries: LedgerEntry[] = [ ...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })), @@ -289,27 +176,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return [{ value: '', label: t('costs.filter.allDays') }, ...days.map(d => ({ value: d, label: fmtDay(d) }))] }, [budgetItems, locale, t]) - // ── settle actions ────────────────────────────────────────────────────── - const settleFlow = async (fromId: number, toId: number, amount: number) => { - try { - await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency)) - loadSettlement() - } catch { toast.error(t('common.unknownError')) } - } - const undoSettlement = async (id: number) => { - try { await budgetApi.deleteSettlement(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) } - } - const settleAll = async () => { - const flows = settlement?.flows || [] - if (!flows.length) return - try { - for (const f of flows) await budgetApi.createSettlement(tripId, withFallbackFx({ from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }, tripCurrency)) - } catch { toast.error(t('common.unknownError')) } - // Refresh even when one transfer failed: the ones created before it are real, - // and leaving them in the flow list invites a second, doubled settle-up. - finally { loadSettlement() } - } - const dateMeta = useMemo(() => { if (!trip?.start_date || !trip?.end_date) return null try { @@ -320,9 +186,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } catch { return null } }, [trip?.start_date, trip?.end_date, locale]) - const handleDelete = async (id: number) => { - try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) } - } // The table's own writes: one cell at a time, and an empty row to type into. const updateFromTable = async (id: number, patch: Partial) => { try { await updateBudgetItem(tripId, id, patch); loadSettlement() } catch { toast.error(t('common.unknownError')) } @@ -336,44 +199,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } } - // CSV export of all expenses — the wiki-documented export that got lost in the - // Costs rework (#1500). One row per expense, oldest first. - const handleExportCsv = () => { - const sep = ';' - // A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel - // and Sheets, and the name/note columns are free text any trip member can write. - const esc = (v: unknown) => { - let s = String(v ?? '') - if (/^[=+\-@\t\r]/.test(s)) s = "'" + s - return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s - } - const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } } - - // Read in another currency than the trip's, what each row counts as in the trip - // currency too, the figure every sum is built from (#2525). - const tripCol = tripCurrency !== base - const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? ['Amount (' + tripCurrency + ')'] : []), 'Amount (' + base + ')', 'Note'] - const rows = [header.join(sep)] - const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')) - for (const e of items) { - const cur = curOf(e) - const note = readUserNote(e) - const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, tripCurrency, convert) - rows.push([ - esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)), - (e.total_price || 0).toFixed(currencyDecimals(cur)), cur, - ...(tripCol ? [inTrip.toFixed(currencyDecimals(tripCurrency))] : []), - baseTotal(e).toFixed(currencyDecimals(base)), - esc(note), - ].join(sep)) - } - - const bom = '' - const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) - const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim() - downloadBlob(blob, `costs-${safeName}.csv`) - } - // ── small presentational helpers ──────────────────────────────────────── const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => { const url = personById(id)?.avatar_url @@ -974,10 +799,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // A settle-up payment as a ledger row — visually distinct from an expense, with // inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal. function SettlementRow({ s }: { s: Settlement }) { - // Legacy transfers carry no currency and were entered in the display base. - const cur = (s.currency || base).toUpperCase() // Booked in the trip currency like an expense, so it is explained the same way. - const line = convertedLine(s.amount, cur, s.exchange_rate, tripCurrency, base, settled(s)) + const line = paymentLineOf(s, ctx, settled(s)) return (
@@ -1015,7 +838,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ) } - function BalancesList({ balances }: { balances: SettlementData['balances'] }) { + function BalancesList({ balances }: { balances: CostsSettlementResponse['balances'] }) { if (settlementError) return loadFailed() const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 }) const max = Math.max(1, ...rows.map(r => Math.abs(r.balance))) @@ -1165,21 +988,18 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps function CategoryBreakdown() { // Categories net refunds against spend (#2176): a negative entry lowers its - // category's sum, and a category that nets negative keeps its own row — - // just without a bar, since the bars rank positive spend. - const tot: Record = {} - for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) } - const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) !== 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0)) - if (rows.length === 0) return
{t('costs.noCategories')}
- // Bars are scaled relative to the most expensive category (the top row fills the - // bar), not to the trip grand total — makes the relative ranking readable. - const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0)) + // category's sum, and a category that nets negative keeps its own row, + // just without a bar, since the bars rank positive spend. Bars are scaled + // relative to the most expensive category (the top row fills the bar), not + // to the trip grand total, which makes the relative ranking readable. + if (catBreakdown.length === 0) return
{t('costs.noCategories')}
return (
- {rows.map(c => { - const v = tot[c.key]; const pct = maxCat > 0 && v > 0 ? v / maxCat * 100 : 0 + {catBreakdown.map(row => { + const c = COST_CAT_META[row.key] + const v = row.amount; const pct = row.widthPct return ( -
+
{t(c.labelKey)} {fmt0(v)} @@ -1259,35 +1079,13 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, }) { const { t } = useTranslation() const toast = useToast() - const otherDefault = people.find(p => p.id !== me)?.id ?? me - const [fromId, setFromId] = useState(String(editing?.from_user_id ?? me)) - const [toId, setToId] = useState(String(editing?.to_user_id ?? otherDefault)) - // Seeded with the transfer's own currency decimals, so a reopened 4,90 reads - // "4.90" and not "4.9" (#2175) — and a JPY transfer gets no fake decimals. - const [amount, setAmount] = useState(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '') - const [cur, setCur] = useState((editing?.currency || currency).toUpperCase()) - const [day, setDay] = useState(editing ? settlementDate(editing) : localToday()) - const [note, setNote] = useState(editing?.note || '') - const [saving, setSaving] = useState(false) - - const amt = Number.parseFloat(amount) || 0 - const valid = amt > 0 && fromId !== toId && !!day + const { fromId, setFromId, toId, setToId, amount, setAmount, currency: cur, setCurrency: setCur, day, setDay, note, setNote, saving, valid, save } = + useSettlementForm({ tripId, tripCurrency, base: currency, people, me, editing, t, toast, onSaved }) const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username })) - const save = async () => { - if (!valid) return - setSaving(true) - const data = withFallbackFx({ from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day, note: note.trim() || null }, tripCurrency) - try { - if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) - else await budgetApi.createSettlement(tripId, data) - onSaved() - } catch { toast.error(t('common.unknownError')) } finally { setSaving(false) } - } - const labelId = useId() - const nameOf = (id: string) => { - const p = people.find(x => String(x.id) === id) + const nameOf = (id: number) => { + const p = people.find(x => x.id === id) if (!p) return '' return p.id === me ? t('costs.you') : p.username } @@ -1317,10 +1115,10 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, >
- setFromId(String(v))} options={opts} style={{ width: '100%' }} /> + setFromId(Number(v))} options={opts} style={{ width: '100%' }} /> - setToId(String(v))} options={opts} style={{ width: '100%' }} /> + setToId(Number(v))} options={opts} style={{ width: '100%' }} />
@@ -1335,7 +1133,7 @@ function SettlementModal({ tripId, people, me, editing, currency, tripCurrency, setCur(String(v))} searchable - options={currenciesWith(cur).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} + options={currencyOptions(cur)} style={{ width: '100%' }} />
@@ -1760,7 +1558,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo setCurrency(String(v))} searchable - options={currenciesWith(currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} + options={currencyOptions(currency)} style={{ width: '100%' }} /> diff --git a/client/src/components/Budget/costsModel.test.ts b/client/src/components/Budget/costsModel.test.ts new file mode 100644 index 000000000..a72a9d081 --- /dev/null +++ b/client/src/components/Budget/costsModel.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { buildBudgetItem } from '../../../tests/helpers/factories'; +import type { BudgetItem } from '../../types'; +import { + buildCostsCsv, + computeTotals, + currencyOptions, + filterBudgetItems, + myShareOf, + paymentAmount, + paymentLineOf, + settledShareOf, + type CostsCtx, + type CostsFilterState, + type CostsSettlement, +} from './costsModel'; + +// FE-BUDGET-COSTSMODEL-001 to FE-BUDGET-COSTSMODEL-010 +// The pieces both ledgers share since the desktop panel runs on this model too; +// the rest of the model is pinned in tests/unit/mobile/trip/costsModel.test.ts. + +const RATES: Record = { EUR: 1, USD: 0.5 }; + +const ctx: CostsCtx = { + me: 1, + tripCurrency: 'EUR', + displayCurrency: 'EUR', + convert: (amount, currency) => amount * (RATES[(currency || 'EUR').toUpperCase()] ?? 1), +}; + +type Member = NonNullable[number]; +type Payer = NonNullable[number]; + +const member = (user_id: number): Member => ({ user_id, paid: 0, username: `u${user_id}` }); +const payer = (user_id: number, amount: number): Payer => ({ user_id, amount, username: `u${user_id}` }); +const expense = (overrides: Partial = {}) => buildBudgetItem({ category: 'other', ...overrides }); +const filters = (overrides: Partial = {}): CostsFilterState => ({ + search: '', + segment: 'all', + categoryKey: '', + dayKey: '', + ...overrides, +}); +const payment = (overrides: Partial = {}): CostsSettlement => ({ + id: 1, + from_user_id: 1, + to_user_id: 2, + amount: 10, + currency: null, + ...overrides, +}); + +// Nobody paid this one yet, and I am in its split. +const unfinished = () => expense({ id: 4, total_price: 80, members: [member(1), member(2)], payers: [] }); +// Bob paid this one in full, and I am in its split. +const paidByBob = () => expense({ id: 6, total_price: 40, members: [member(1), member(2)], payers: [payer(2, 40)] }); + +describe('costsModel: the share of an unfinished expense', () => { + it('FE-BUDGET-COSTSMODEL-001: settledShareOf leaves an expense nobody paid out, myShareOf counts it (#2225)', () => { + expect(myShareOf(unfinished(), ctx)).toBe(40); + expect(settledShareOf(unfinished(), ctx)).toBe(0); + expect(settledShareOf(paidByBob(), ctx)).toBe(20); + }); + + it('FE-BUDGET-COSTSMODEL-002: computeTotals counts my share with the share rule it is given', () => { + const items = [unfinished(), paidByBob()]; + expect(computeTotals(items, [], ctx).myShare).toBe(60); + expect(computeTotals(items, [], ctx, settledShareOf).myShare).toBe(20); + expect(computeTotals(items, [], ctx, settledShareOf).outstanding).toBe(80); + }); + + it('FE-BUDGET-COSTSMODEL-003: the "owed" filter nets what I paid against the share rule it is given', () => { + // A refund nobody has been named the recipient of yet: my share of it is negative. + const refund = expense({ id: 5, total_price: -80, members: [member(1), member(2)], payers: [] }); + const iPaid = expense({ id: 8, total_price: 30, members: [member(1), member(2)], payers: [payer(1, 30)] }); + const items = [refund, iPaid]; + expect(filterBudgetItems(items, filters({ segment: 'owed' }), ctx).map((e) => e.id)).toEqual([5, 8]); + expect(filterBudgetItems(items, filters({ segment: 'owed' }), ctx, settledShareOf).map((e) => e.id)).toEqual([8]); + }); +}); + +describe('costsModel: recorded payments', () => { + it('FE-BUDGET-COSTSMODEL-004: a payment converts at the rate frozen when it was settled (#1445)', () => { + expect(paymentAmount(payment({ amount: 10, currency: 'USD', exchange_rate: 2 }), ctx)).toBe(5); + // No frozen rate: today's rate. + expect(paymentAmount(payment({ amount: 10, currency: 'usd' }), ctx)).toBe(5); + }); + + it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => { + const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' }; + expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5); + expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10); + }); + + it('FE-BUDGET-COSTSMODEL-006: the line under a payment explains it the way an expense is explained (#2525)', () => { + expect(paymentLineOf(payment({ amount: 10, currency: 'EUR' }), ctx, 10)).toBeNull(); + expect(paymentLineOf(payment({ amount: 10, currency: 'usd' }), ctx, 5)).toEqual({ + entered: { amount: 10, currency: 'USD' }, + into: { amount: 5, currency: 'EUR' }, + }); + }); +}); + +describe('costsModel: the CSV currency column', () => { + const t = (key: string) => key; + const rows = (currencyOf?: (e: BudgetItem) => string) => + buildCostsCsv([expense({ name: 'Taxi', total_price: 12, currency: 'usd', expense_date: '2026-07-01' })], { + base: 'EUR', + ctx, + locale: 'en-US', + t, + currencyOf, + }).content.split('\r\n'); + + it('FE-BUDGET-COSTSMODEL-007: by default the currency is normalised to upper case', () => { + expect(rows()[1].split(';')[4]).toBe('USD'); + }); + + it('FE-BUDGET-COSTSMODEL-008: a caller can write each expense currency as stored', () => { + expect(rows((e) => e.currency || ctx.tripCurrency)[1].split(';')[4]).toBe('usd'); + }); +}); + +describe('costsModel: the currency picker options', () => { + it('FE-BUDGET-COSTSMODEL-009: labels a supported currency with its code and symbol', () => { + const eur = currencyOptions('EUR').find((o) => o.value === 'EUR'); + expect(eur).toEqual({ value: 'EUR', label: 'EUR €' }); + expect(currencyOptions('EUR').filter((o) => o.value === 'EUR')).toHaveLength(1); + }); + + it('FE-BUDGET-COSTSMODEL-010: keeps a retired currency selectable, labelled by its code alone', () => { + const opts = currencyOptions('bgn'); + expect(opts[opts.length - 1]).toEqual({ value: 'BGN', label: 'BGN' }); + expect(currencyOptions(null).some((o) => o.value === 'BGN')).toBe(false); + }); +}); diff --git a/client/src/components/Budget/costsModel.ts b/client/src/components/Budget/costsModel.ts new file mode 100644 index 000000000..2f3ef9a49 --- /dev/null +++ b/client/src/components/Budget/costsModel.ts @@ -0,0 +1,428 @@ +import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared'; +import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates'; +import type { BudgetItem } from '../../types'; +import { currencyDecimals } from '../../utils/formatters'; +import { currenciesWith, SYMBOLS } from './BudgetPanel.constants'; +import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers'; +import { catMeta, COST_CATEGORY_LIST } from './costsCategories'; + +/** + * Costs view-model: the ledger maths both Costs surfaces run on, the desktop + * `CostsPanel.tsx` and the phone `MCostsTab.tsx` (baseTotal, myPaidOf, the + * shares, isUnfinished, totals, filters, payments, the category breakdown and + * the CSV), so the two never disagree. Decoupled from React so it stays + * testable. Money is converted to the display/base currency via an injected + * `convert` (from `useExchangeRates`), never computed here. + */ + +/** Per-render context every money computation needs — cheap to build, memoize in the caller. */ +export interface CostsCtx { + me: number; + /** The trip's own currency — what a NULL `budget_items.currency` means. */ + tripCurrency: string; + /** The currency everything is shown in, the base `convert` converts to. */ + displayCurrency: string; + convert: (amount: number, currency: string | null | undefined) => number; +} + +/** An expense's own currency, defaulting to the trip currency (NULL column = trip currency). */ +export function currencyOf(e: BudgetItem, ctx: CostsCtx): string { + return (e.currency || ctx.tripCurrency).toUpperCase(); +} + +/** An amount of this expense in the display currency, at the rate the expense was booked at. */ +export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number { + return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert); +} + +/** + * The line under an amount of this expense the list shows converted, what was entered and + * where it went (#2525); null when there is nothing to explain. `shown` is the display + * value printed beside it. + */ +export function lineOf(amount: number, e: BudgetItem, ctx: CostsCtx, shown: number) { + return convertedLine(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.displayCurrency, shown); +} + +/** Expense total converted to the display/base currency, at its booked rate. */ +export function baseTotal(e: BudgetItem, ctx: CostsCtx): number { + return booked(e.total_price || 0, e, ctx); +} + +/** How much `ctx.me` personally fronted for this expense, in the base currency. */ +export function myPaidOf(e: BudgetItem, ctx: CostsCtx): number { + return booked(paidByUser(e, ctx.me), e, ctx); +} + +/** A given member's share of this expense (explicit custom amount, else equal split), base currency. */ +export function memberShareOf(e: BudgetItem, userId: number, ctx: CostsCtx): number { + const member = (e.members || []).find((m) => m.user_id === userId); + if (!member) return 0; + if (member.amount !== null && member.amount !== undefined) { + return booked(member.amount, e, ctx); + } + const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id); + return booked(shares[userId] || 0, e, ctx); +} + +/** `ctx.me`'s own share — the common case of {@link memberShareOf}. */ +export function myShareOf(e: BudgetItem, ctx: CostsCtx): number { + return memberShareOf(e, ctx.me, ctx); +} + +/** How a ledger counts `ctx.me`'s share of one expense, for the totals and the "owed" filter. */ +export type ShareOf = (e: BudgetItem, ctx: CostsCtx) => number; + +/** + * A recorded total nobody has actually paid yet — counts toward the trip total + * but stays out of settlement. A negative total (a refund, #2176) is just as + * unfinished until its recipient is recorded as the (negative) payer. + */ +export function isUnfinished(e: BudgetItem, ctx: CostsCtx): boolean { + return baseTotal(e, ctx) !== 0 && (e.payers || []).filter((p) => p.amount !== 0).length === 0; +} + +/** + * `ctx.me`'s share as the desktop ledger counts it: nobody paid, so nobody owes. + * The ledger skips an unfinished expense entirely (#2225), and counting it here + * left the tile contradicting the balances right beside it. + */ +export function settledShareOf(e: BudgetItem, ctx: CostsCtx): number { + return isUnfinished(e, ctx) ? 0 : myShareOf(e, ctx); +} + +// ── settlement (server-computed; these types describe what both ledgers read from it) ── + +export interface CostsSettlementFlow { + from: { user_id: number; username: string; avatar_url?: string | null }; + to: { user_id: number; username: string; avatar_url?: string | null }; + amount: number; +} + +export interface CostsBalance { + user_id: number; + username: string; + avatar_url: string | null; + balance: number; +} + +/** A recorded settle-up transfer ("Ajouter un paiement") — history, not a suggestion like {@link CostsSettlementFlow}. */ +export interface CostsSettlement { + id: number; + from_user_id: number; + to_user_id: number; + amount: number; + // Legacy rows predate this column (null) and are read as the display currency. + currency?: string | null; + // The rate frozen when the transfer was settled, units of `currency` per 1 trip + // currency (#1445). Absent, or exactly 1, on rows written before the freeze existed. + exchange_rate?: number; + created_at?: string; + // The day the transfer actually happened; editable, unlike created_at (when it + // was recorded). Null/absent on rows predating this field. + settled_at?: string | null; + /** A free-text note on the payment (#2340). */ + note?: string | null; +} + +/** The currency a recorded payment was entered in: legacy rows without one were in the display currency. */ +function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string { + return (s.currency || ctx.displayCurrency).toUpperCase(); +} + +/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */ +export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number { + return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert); +} + +/** The line under a payment shown converted, explained the way an expense's is (#2525). */ +export function paymentLineOf(s: CostsSettlement, ctx: CostsCtx, shown: number) { + return convertedLine( + s.amount, + paymentCurrency(s, ctx), + s.exchange_rate, + ctx.tripCurrency, + ctx.displayCurrency, + shown + ); +} + +export interface CostsSettlementResponse { + balances: CostsBalance[]; + flows: CostsSettlementFlow[]; + settlements: CostsSettlement[]; + /** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */ + finalBudgets: BudgetParticipantFinal[]; + /** The currency the figures are in: the display currency, or the trip's own without a quote for it. */ + currency?: string; + /** Rows no rate could convert, left out of every figure above. */ + unconverted?: BudgetUnconverted; +} + +// ── hero / tile totals (spec §3.1-§3.3) ──────────────────────────────────── + +export interface CostsTotals { + totalSpend: number; + myPaid: number; + myShare: number; + owe: number; + owed: number; + outstanding: number; + outstandingCount: number; +} + +export function computeTotals( + items: BudgetItem[], + flows: CostsSettlementFlow[], + ctx: CostsCtx, + shareOf: ShareOf = myShareOf +): CostsTotals { + const totalSpend = items.reduce((a, e) => a + baseTotal(e, ctx), 0); + const myPaid = items.reduce((a, e) => a + myPaidOf(e, ctx), 0); + const myShare = items.reduce((a, e) => a + shareOf(e, ctx), 0); + const owe = flows.filter((f) => f.from.user_id === ctx.me).reduce((a, f) => a + f.amount, 0); + const owed = flows.filter((f) => f.to.user_id === ctx.me).reduce((a, f) => a + f.amount, 0); + const outstandingItems = items.filter((e) => isUnfinished(e, ctx)); + const outstanding = outstandingItems.reduce((a, e) => a + baseTotal(e, ctx), 0); + return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount: outstandingItems.length }; +} + +// ── expenses list: filter + group (spec §3.6-§3.7) ───────────────────────── + +export type CostsSegment = 'all' | 'mine' | 'owed'; + +export interface CostsFilterState { + search: string; + segment: CostsSegment; + /** '' = all categories */ + categoryKey: string; + /** '' = all days, else the expense's own YYYY-MM-DD */ + dayKey: string; +} + +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** + * `mine`/`owed` use the real balance, not the demo's `payer==='you'` shortcut + * (spec 03 §3.6): `mine` = I fronted money on it, `owed` = I'm net owed on it. + */ +export function filterBudgetItems( + items: BudgetItem[], + f: CostsFilterState, + ctx: CostsCtx, + shareOf: ShareOf = myShareOf +): BudgetItem[] { + let list = items.slice(); + if (f.segment === 'mine') list = list.filter((e) => myPaidOf(e, ctx) > 0); + if (f.segment === 'owed') list = list.filter((e) => round2(myPaidOf(e, ctx) - shareOf(e, ctx)) > 0); + if (f.categoryKey) list = list.filter((e) => catMeta(e.category).key === f.categoryKey); + if (f.dayKey) list = list.filter((e) => (e.expense_date || '') === f.dayKey); + const q = f.search.trim().toLowerCase(); + if (q) list = list.filter((e) => e.name.toLowerCase().includes(q)); + return list; +} + +export interface CostsDayGroup { + /** '' = no date (spec's "NO DATE" group) */ + dateKey: string; + items: BudgetItem[]; +} + +/** Groups by `expense_date`, newest first, the no-date bucket sinking to the end (spec §3.7). */ +export function groupByDay(items: BudgetItem[]): CostsDayGroup[] { + const byDate = new Map(); + for (const e of items) { + const key = e.expense_date || ''; + const bucket = byDate.get(key); + if (bucket) bucket.push(e); + else byDate.set(key, [e]); + } + const keys = Array.from(byDate.keys()).sort((a, b) => { + if (a === b) return 0; + if (a === '') return 1; + if (b === '') return -1; + return b.localeCompare(a); + }); + return keys.map((dateKey) => ({ dateKey, items: byDate.get(dateKey) as BudgetItem[] })); +} + +/** + * Settlements ("payments") shown inline in the ledger: they have no name or + * category, so a text or category filter hides them; "owed" excludes them; + * "mine" keeps only transfers the current user is part of. + */ +export function filterSettlements(settlements: CostsSettlement[], f: CostsFilterState, me: number): CostsSettlement[] { + if (f.search.trim() || f.categoryKey) return []; + if (f.segment === 'owed') return []; + let list = settlements.slice(); + if (f.segment === 'mine') list = list.filter((s) => s.from_user_id === me || s.to_user_id === me); + if (f.dayKey) list = list.filter((s) => settlementDate(s) === f.dayKey); + return list; +} + +export type CostsLedgerEntry = + { kind: 'expense'; date: string; item: BudgetItem } | { kind: 'payment'; date: string; settlement: CostsSettlement }; + +export interface CostsLedgerDayGroup { + /** '' = no date (spec's "NO DATE" group) */ + dateKey: string; + entries: CostsLedgerEntry[]; +} + +/** + * Like {@link groupByDay}, but also folds in settlement payments (see + * {@link filterSettlements}) as their own ledger entries, keyed by + * {@link settlementDate} — the mobile counterpart to desktop's unified + * `LedgerEntry` grouping, so a payment shows up even on a day with no expense. + */ +export function groupLedgerByDay(items: BudgetItem[], settlements: CostsSettlement[]): CostsLedgerDayGroup[] { + const entries: CostsLedgerEntry[] = [ + ...items.map((item) => ({ kind: 'expense' as const, date: item.expense_date || '', item })), + ...settlements.map((settlement) => ({ kind: 'payment' as const, date: settlementDate(settlement), settlement })), + ]; + const byDate = new Map(); + for (const en of entries) { + const bucket = byDate.get(en.date); + if (bucket) bucket.push(en); + else byDate.set(en.date, [en]); + } + const keys = Array.from(byDate.keys()).sort((a, b) => { + if (a === b) return 0; + if (a === '') return 1; + if (b === '') return -1; + return b.localeCompare(a); + }); + return keys.map((dateKey) => ({ dateKey, entries: byDate.get(dateKey) as CostsLedgerEntry[] })); +} + +/** Categories present among `items`, canonical order — the dropdown only lists categories in use (spec §3.6). */ +export function categoryFilterKeys(items: BudgetItem[]): CostCategory[] { + const present = new Set(items.map((e) => catMeta(e.category).key)); + return COST_CATEGORY_LIST.map((c) => c.key).filter((k) => present.has(k)); +} + +/** Distinct expense dates, ascending (spec §3.6: "Tage mit Ausgaben aufsteigend"). */ +export function dayFilterKeys(items: BudgetItem[]): string[] { + const dates = new Set(items.map((e) => e.expense_date).filter((d): d is string => Boolean(d))); + return Array.from(dates).sort((a, b) => a.localeCompare(b)); +} + +// ── by-category breakdown (spec §3.5) ─────────────────────────────────────── + +export interface CostsCategoryBar { + key: CostCategory; + amount: number; + /** 0-100, relative to the largest category (not the grand total) — spec §3.5. */ + widthPct: number; +} + +export function categoryBreakdown(items: BudgetItem[], ctx: CostsCtx): CostsCategoryBar[] { + // Categories net refunds against spend (#2176): a negative entry lowers its + // category's sum. A category that nets negative keeps its own row at the + // bottom, with widthPct 0 — the bars rank positive spend, and a negative + // CSS width would be dropped and render as a full bar. + const totals = new Map(); + for (const e of items) { + const key = catMeta(e.category).key; + totals.set(key, (totals.get(key) || 0) + baseTotal(e, ctx)); + } + const rows = COST_CATEGORY_LIST.map((c) => ({ key: c.key, amount: totals.get(c.key) || 0 })) + .filter((r) => r.amount !== 0) + .sort((a, b) => b.amount - a.amount); + const max = Math.max(0, ...rows.map((r) => r.amount)); + return rows.map((r) => ({ ...r, widthPct: max > 0 && r.amount > 0 ? (r.amount / max) * 100 : 0 })); +} + +// ── presentation helpers ───────────────────────────────────────────────── + +/** Category colour at a fixed alpha — the expense card's border tint (spec §3.10: "brC"). */ +export function tint(hex: string, alpha: number): string { + const clean = hex.replace('#', ''); + const r = Number.parseInt(clean.slice(0, 2), 16); + const g = Number.parseInt(clean.slice(2, 4), 16); + const b = Number.parseInt(clean.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +// ── CSV export (spec §3.8 `csvGo`; string-building only — the caller turns this into a download) ── + +export interface CsvBuildOptions { + base: string; + ctx: CostsCtx; + locale: string; + tripTitle?: string | null; + t: (key: string) => string; + /** The Currency column; {@link currencyOf} unless the caller writes it as stored. */ + currencyOf?: (e: BudgetItem) => string; +} + +/** The Costs CSV export's rows, one per expense, oldest first; the Blob/download is a DOM concern left to the caller. */ +export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { filename: string; content: string } { + const sep = ';'; + // A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel + // and Sheets, and the name/note columns are free text any trip member can write. + const esc = (v: unknown) => { + let s = String(v ?? ''); + if (/^[=+\-@\t\r]/.test(s)) s = "'" + s; + return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s; + }; + const fmtDate = (iso: string) => { + if (!iso) return ''; + try { + return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: 'UTC', + }); + } catch { + return iso; + } + }; + + // Read in another currency than the trip's, what each row counts as in the trip currency + // too, the figure every sum is built from (#2525). + const trip = opts.ctx.tripCurrency.toUpperCase(); + const tripCol = trip !== opts.base.toUpperCase(); + const header = [ + 'Date', + 'Name', + 'Category', + 'Amount', + 'Currency', + ...(tripCol ? [`Amount (${trip})`] : []), + `Amount (${opts.base})`, + 'Note', + ]; + const rows = [header.join(sep)]; + const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')); + for (const e of sorted) { + const cur = opts.currencyOf ? opts.currencyOf(e) : currencyOf(e, opts.ctx); + const note = readUserNote(e); + const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, trip, opts.ctx.convert); + rows.push( + [ + esc(fmtDate(e.expense_date || '')), + esc(e.name), + esc(opts.t(catMeta(e.category).labelKey)), + (e.total_price || 0).toFixed(currencyDecimals(cur)), + cur, + ...(tripCol ? [inTrip.toFixed(currencyDecimals(trip))] : []), + baseTotal(e, opts.ctx).toFixed(currencyDecimals(opts.base)), + esc(note), + ].join(sep) + ); + } + + const safeName = (opts.tripTitle || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim(); + return { filename: `costs-${safeName}.csv`, content: rows.join('\r\n') }; +} + +/** + * The currency picker of every Costs form (expense and payment, desktop and + * phone): the supported currencies plus the one already chosen, each labelled + * with its code and, when it has one, its symbol. + */ +export function currencyOptions(current?: string | null): { value: string; label: string }[] { + return currenciesWith(current).map((c) => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c })); +} diff --git a/client/src/components/Budget/useCostsLedger.test.ts b/client/src/components/Budget/useCostsLedger.test.ts new file mode 100644 index 000000000..56fe3cd19 --- /dev/null +++ b/client/src/components/Budget/useCostsLedger.test.ts @@ -0,0 +1,306 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { budgetApi } from '../../api/client'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import type { BudgetItem, Trip } from '../../types'; +import { downloadBlob } from '../../utils/fileDownload'; +import type { CostsSettlementResponse } from './costsModel'; +import { useCostsLedger } from './useCostsLedger'; +import { useFreezeMissingRates } from './useFreezeMissingRates'; + +// FE-BUDGET-LEDGER-001 to FE-BUDGET-LEDGER-012 + +const fx = vi.hoisted(() => ({ + // One stable converter, the way useExchangeRates hands out a memoised one: USD is half a euro. + rates: { + convert: (amount: number, currency: string | null | undefined) => + (currency || 'EUR').toUpperCase() === 'USD' ? amount * 0.5 : amount, + displayPerTrip: 1.1, + }, +})); + +vi.mock('../../hooks/useExchangeRates', async (importOriginal) => ({ + ...(await importOriginal()), + useExchangeRates: vi.fn(() => fx.rates), +})); +vi.mock('../../api/client', async (importOriginal) => ({ + ...(await importOriginal()), + budgetApi: { + settlement: vi.fn(), + createSettlement: vi.fn(), + deleteSettlement: vi.fn(), + }, +})); +vi.mock('./useFreezeMissingRates', () => ({ useFreezeMissingRates: vi.fn() })); +vi.mock('../../utils/fileDownload', () => ({ downloadBlob: vi.fn() })); + +type Member = NonNullable[number]; +type Payer = NonNullable[number]; +const member = (user_id: number): Member => ({ user_id, paid: 0, username: `u${user_id}` }); +const payer = (user_id: number, amount: number): Payer => ({ user_id, amount, username: `u${user_id}` }); + +const flow = (from: number, to: number, amount: number) => ({ + from: { user_id: from, username: `u${from}` }, + to: { user_id: to, username: `u${to}` }, + amount, +}); + +const SETTLEMENT: CostsSettlementResponse = { + balances: [], + flows: [flow(1, 2, 15), flow(3, 1, 5)], + settlements: [{ id: 30, from_user_id: 1, to_user_id: 2, amount: 4, created_at: '2026-07-02T09:00:00Z' }], + finalBudgets: [], +}; + +// I am in the split of an expense nobody has paid yet. +const unfinished = () => + buildBudgetItem({ id: 4, name: 'Hotel', total_price: 80, members: [member(1), member(2)], payers: [] }); +const paidByBob = () => + buildBudgetItem({ + id: 6, + name: 'Taxi', + total_price: 40, + currency: 'usd', + members: [member(1), member(2)], + payers: [payer(2, 40)], + expense_date: '2026-07-01', + }); + +let loadBudgetItems: Mock<(tripId: number | string) => Promise>; +let deleteBudgetItem: Mock<(tripId: number | string, id: number) => Promise>; +let toastError: Mock<(message: string) => void>; + +interface Options { + trip?: Trip; + items?: BudgetItem[]; + settlementOnTripChange?: boolean; + skipUnfinishedShares?: boolean; + csvCurrencyAsStored?: boolean; +} + +function setup(options: Options = {}) { + const props = { tripId: 7, items: options.items ?? [unfinished(), paidByBob()] }; + return renderHook( + ({ tripId, items }: { tripId: number; items: BudgetItem[] }) => + useCostsLedger({ + tripId, + trip: options.trip ?? buildTrip({ id: 7, currency: 'EUR', title: 'Rome' }), + budgetItems: items, + actions: { loadBudgetItems, deleteBudgetItem }, + canEdit: true, + t: (key: string) => key, + toast: { error: toastError }, + settlementOnTripChange: options.settlementOnTripChange, + skipUnfinishedShares: options.skipUnfinishedShares, + csvCurrencyAsStored: options.csvCurrencyAsStored, + }), + { initialProps: props } + ); +} + +const settlementMock = () => vi.mocked(budgetApi.settlement); +const createMock = () => vi.mocked(budgetApi.createSettlement); +const deleteMock = () => vi.mocked(budgetApi.deleteSettlement); + +beforeEach(() => { + resetAllStores(); + vi.clearAllMocks(); + seedStore(useAuthStore, { user: { id: 1 } }); + useSettingsStore.setState((s) => ({ settings: { ...s.settings, default_currency: '' } })); + loadBudgetItems = vi.fn<(tripId: number | string) => Promise>(async () => undefined); + deleteBudgetItem = vi.fn<(tripId: number | string, id: number) => Promise>(async () => undefined); + toastError = vi.fn<(message: string) => void>(); + settlementMock().mockResolvedValue(SETTLEMENT); + createMock().mockResolvedValue({}); + deleteMock().mockResolvedValue({}); +}); + +describe('useCostsLedger: loading', () => { + it('FE-BUDGET-LEDGER-001: loads the items and reads the settlement in the display currency', async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.settlement).toEqual(SETTLEMENT)); + expect(loadBudgetItems).toHaveBeenCalledTimes(1); + expect(loadBudgetItems).toHaveBeenCalledWith(7); + expect(settlementMock()).toHaveBeenCalledTimes(1); + // Display and trip currency agree, so no browser rate goes along. + expect(settlementMock()).toHaveBeenCalledWith(7, 'EUR', null); + expect(result.current.base).toBe('EUR'); + expect(result.current.tripCurrency).toBe('EUR'); + expect(result.current.me).toBe(1); + }); + + it('FE-BUDGET-LEDGER-002: a display currency other than the trip one sends the browser rate along', async () => { + useSettingsStore.setState((s) => ({ settings: { ...s.settings, default_currency: 'usd' } })); + const { result } = setup(); + await waitFor(() => expect(result.current.settlement).not.toBeNull()); + expect(result.current.base).toBe('USD'); + expect(settlementMock()).toHaveBeenCalledWith(7, 'USD', 1.1); + }); + + it('FE-BUDGET-LEDGER-003: the desktop also reads the settlement on a trip change, the phone only on the expense count', async () => { + const desktop = setup({ settlementOnTripChange: true }); + await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(2)); + desktop.unmount(); + + settlementMock().mockClear(); + const phone = setup(); + await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(1)); + phone.rerender({ tripId: 8, items: [unfinished(), paidByBob()] }); + // A new trip id gets a new settlement read through loadSettlement's own change. + await waitFor(() => expect(loadBudgetItems).toHaveBeenLastCalledWith(8)); + expect(settlementMock()).toHaveBeenLastCalledWith(8, 'EUR', null); + }); + + it('FE-BUDGET-LEDGER-004: a new expense count reads the settlement again, a re-render with the same count does not', async () => { + const { rerender } = setup(); + await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(1)); + rerender({ tripId: 7, items: [unfinished(), paidByBob()] }); + expect(settlementMock()).toHaveBeenCalledTimes(1); + rerender({ tripId: 7, items: [unfinished()] }); + await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(2)); + expect(loadBudgetItems).toHaveBeenCalledTimes(1); + }); + + it('FE-BUDGET-LEDGER-005: a failed read flags the settlement as unknown until a read succeeds', async () => { + settlementMock().mockRejectedValueOnce(new Error('offline')); + const { result } = setup(); + await waitFor(() => expect(result.current.settlementError).toBe(true)); + expect(result.current.settlement).toBeNull(); + act(() => result.current.loadSettlement()); + await waitFor(() => expect(result.current.settlementError).toBe(false)); + expect(result.current.settlement).toEqual(SETTLEMENT); + }); + + it('FE-BUDGET-LEDGER-006: hands the unconverted rows to the rate freeze, which reads the settlement again once healed', async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.settlement).not.toBeNull()); + const last = vi.mocked(useFreezeMissingRates).mock.lastCall?.[0]; + expect(last).toMatchObject({ tripId: 7, tripCurrency: 'EUR', canEdit: true, unconverted: undefined }); + act(() => last?.onHealed()); + await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(2)); + }); +}); + +describe('useCostsLedger: totals and filters', () => { + it('FE-BUDGET-LEDGER-007: the desktop leaves an expense nobody paid out of my share, the phone counts it (#2225)', async () => { + const phone = setup(); + await waitFor(() => expect(phone.result.current.settlement).not.toBeNull()); + expect(phone.result.current.totals).toEqual({ + totalSpend: 100, + myPaid: 0, + myShare: 50, + owe: 15, + owed: 5, + outstanding: 80, + outstandingCount: 1, + }); + phone.unmount(); + + const desktop = setup({ skipUnfinishedShares: true }); + await waitFor(() => expect(desktop.result.current.settlement).not.toBeNull()); + expect(desktop.result.current.totals.myShare).toBe(10); + expect(desktop.result.current.flows).toEqual(SETTLEMENT.flows); + }); + + it('FE-BUDGET-LEDGER-008: the filters narrow the expenses and the payments together', async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.settlement).not.toBeNull()); + expect(result.current.filtered.map((e) => e.id)).toEqual([4, 6]); + expect(result.current.filteredSettlements.map((s) => s.id)).toEqual([30]); + expect(result.current.catBreakdown.map((r) => r.amount)).toEqual([100]); + + act(() => result.current.setSearch('taxi')); + expect(result.current.filtered.map((e) => e.id)).toEqual([6]); + expect(result.current.filteredSettlements).toEqual([]); + + act(() => { + result.current.setSearch(''); + result.current.setDayFilter('2026-07-02'); + }); + expect(result.current.filtered).toEqual([]); + expect(result.current.filteredSettlements.map((s) => s.id)).toEqual([30]); + + act(() => { + result.current.setDayFilter(''); + result.current.setSegment('owed'); + result.current.setCatFilter(''); + }); + expect(result.current.segment).toBe('owed'); + expect(result.current.filteredSettlements).toEqual([]); + }); +}); + +describe('useCostsLedger: writes', () => { + it('FE-BUDGET-LEDGER-009: deleting an expense reads the settlement again, a failure toasts', async () => { + const { result } = setup(); + await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(1)); + await act(async () => { + await result.current.deleteExpense(4); + }); + expect(deleteBudgetItem).toHaveBeenCalledWith(7, 4); + expect(settlementMock()).toHaveBeenCalledTimes(2); + + deleteBudgetItem.mockRejectedValueOnce(new Error('nope')); + await act(async () => { + await result.current.deleteExpense(4); + }); + expect(toastError).toHaveBeenCalledWith('common.unknownError'); + expect(settlementMock()).toHaveBeenCalledTimes(2); + }); + + it('FE-BUDGET-LEDGER-010: undoing a payment deletes it and reads the settlement again, a failure toasts', async () => { + const { result } = setup(); + await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(1)); + await act(async () => { + await result.current.undoSettlement(30); + }); + expect(deleteMock()).toHaveBeenCalledWith(7, 30); + expect(settlementMock()).toHaveBeenCalledTimes(2); + + deleteMock().mockRejectedValueOnce(new Error('nope')); + await act(async () => { + await result.current.undoSettlement(30); + }); + expect(toastError).toHaveBeenCalledWith('common.unknownError'); + }); + + it('FE-BUDGET-LEDGER-011: settling one flow or all of them records transfers in the display currency', async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.settlement).not.toBeNull()); + await act(async () => { + await result.current.settleFlow(1, 2, 15); + }); + expect(createMock()).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 15, currency: 'EUR' }); + expect(settlementMock()).toHaveBeenCalledTimes(2); + + createMock().mockClear(); + // The second transfer fails: the first is real, so the settlement is still read again. + createMock().mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('nope')); + await act(async () => { + await result.current.settleAll(); + }); + expect(createMock()).toHaveBeenCalledTimes(2); + expect(createMock()).toHaveBeenNthCalledWith(2, 7, { from_user_id: 3, to_user_id: 1, amount: 5, currency: 'EUR' }); + expect(toastError).toHaveBeenCalledWith('common.unknownError'); + expect(settlementMock()).toHaveBeenCalledTimes(3); + }); + + it('FE-BUDGET-LEDGER-012: the CSV writes the currency normalised, or as stored when the desktop asks', async () => { + const readCsv = async (csvCurrencyAsStored: boolean) => { + vi.mocked(downloadBlob).mockClear(); + const view = setup({ csvCurrencyAsStored, items: [paidByBob()] }); + act(() => view.result.current.exportCsv()); + const [blob, filename] = vi.mocked(downloadBlob).mock.calls[0]; + view.unmount(); + expect(filename).toBe('costs-Rome.csv'); + const text = await (blob as Blob).text(); + return text.split('\r\n')[1].split(';')[4]; + }; + expect(await readCsv(false)).toBe('USD'); + expect(await readCsv(true)).toBe('usd'); + }); +}); diff --git a/client/src/components/Budget/useCostsLedger.ts b/client/src/components/Budget/useCostsLedger.ts new file mode 100644 index 000000000..fdc106a68 --- /dev/null +++ b/client/src/components/Budget/useCostsLedger.ts @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'; + +import { budgetApi } from '../../api/client'; +import { useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates'; +import { useTranslation } from '../../i18n'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import type { TripStoreState } from '../../store/tripStore'; +import type { BudgetItem, Trip } from '../../types'; +import { downloadBlob } from '../../utils/fileDownload'; +import { + buildCostsCsv, + categoryBreakdown, + computeTotals, + filterBudgetItems, + filterSettlements, + myShareOf, + settledShareOf, + type CostsCtx, + type CostsSegment, + type CostsSettlementResponse, +} from './costsModel'; +import { useFreezeMissingRates } from './useFreezeMissingRates'; + +type Translate = (key: string, params?: Record) => string; +interface Toaster { + error: (message: string) => void; +} + +/** + * The Costs ledger behind the desktop CostsPanel and the phone MCostsTab: the + * display and trip currencies, the settlement the server nets the balances in, + * the totals and filters over the expenses and payments, the category + * breakdown, the CSV export and the ledger writes. Each surface lays it out + * and keeps its own dialogs or sheets. + * + * Where the two differ today, the surface says which way: the desktop also + * reads the settlement when the trip changes (`settlementOnTripChange`), leaves + * an expense nobody paid out of my share (`skipUnfinishedShares`, #2225) and + * writes each expense's currency into the CSV as stored (`csvCurrencyAsStored`). + */ +export function useCostsLedger({ + tripId, + trip, + budgetItems, + actions, + canEdit, + t, + toast, + settlementOnTripChange = false, + skipUnfinishedShares = false, + csvCurrencyAsStored = false, +}: { + tripId: number; + trip: Trip | null | undefined; + budgetItems: BudgetItem[]; + actions: Pick; + canEdit: boolean; + t: Translate; + toast: Toaster; + settlementOnTripChange?: boolean; + skipUnfinishedShares?: boolean; + csvCurrencyAsStored?: boolean; +}) { + const { loadBudgetItems, deleteBudgetItem } = actions; + const { locale } = useTranslation(); + const me = useAuthStore((s) => s.user?.id ?? -1); + + // Display/base currency = the user's preferred currency (Settings), falling back + // to the trip's own currency. Everything in Costs is converted to and shown in it. + const displayCurrency = useSettingsStore((s) => s.settings.default_currency); + const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase(); + // Pre-rework rows stored currency = NULL, meaning "the trip's own currency". + const tripCurrency = (trip?.currency || base).toUpperCase(); + // Anchored on the trip currency's quote, the one the server books with (#2525). + const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency); + const ctx: CostsCtx = useMemo( + () => ({ me, tripCurrency, displayCurrency: base, convert }), + [me, tripCurrency, base, convert] + ); + + const [settlement, setSettlement] = useState(null); + // A failed settlement read leaves `settlement` null, which the views would + // otherwise present as "everyone is square", a balance claim we cannot make. + const [settlementError, setSettlementError] = useState(false); + + // The browser's own figure for the display currency goes along, for the server to + // answer in it when it cannot fetch a quote itself. + const loadSettlement = useCallback(() => { + budgetApi + .settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) + .then((s) => { + setSettlement(s); + setSettlementError(false); + }) + .catch(() => setSettlementError(true)); + }, [tripId, base, tripCurrency, displayPerTrip]); + + // The items reload when the trip changes; the settlement reloads on a trip or + // currency change and when the number of expenses changes. Further refreshes are + // explicit after each write, so an unrelated re-render does not refetch it. The + // count is for an expense saved outside the ledger: a scanned receipt is reviewed + // in the trip sheets, which reload the items but cannot reach this settlement. + const onTripChange = useEffectEvent(() => { + void loadBudgetItems(tripId); + if (settlementOnTripChange) loadSettlement(); + }); + useEffect(() => { + onTripChange(); + }, [tripId, loadBudgetItems]); + useEffect(() => { + loadSettlement(); + }, [budgetItems.length, loadSettlement]); + + // Rows the server could not count get a rate frozen from the browser's, and the + // settlement is read again once they count. + useFreezeMissingRates({ + tripId, + tripCurrency, + canEdit, + unconverted: settlement?.unconverted, + onHealed: loadSettlement, + }); + + const [search, setSearch] = useState(''); + const [segment, setSegment] = useState('all'); + const [catFilter, setCatFilter] = useState(''); // '' = all categories + const [dayFilter, setDayFilter] = useState(''); // '' = all days, else YYYY-MM-DD + + const shareOf = skipUnfinishedShares ? settledShareOf : myShareOf; + const flows = useMemo(() => settlement?.flows || [], [settlement]); + // `ctx` carries the rates. They can land after the expenses, and without it in the + // deps the cards kept the sums they were first added up with while the rows moved on. + const totals = useMemo(() => computeTotals(budgetItems, flows, ctx, shareOf), [budgetItems, flows, ctx, shareOf]); + const filtered = useMemo( + () => filterBudgetItems(budgetItems, { search, segment, categoryKey: catFilter, dayKey: dayFilter }, ctx, shareOf), + [budgetItems, search, segment, catFilter, dayFilter, ctx, shareOf] + ); + const filteredSettlements = useMemo( + () => + filterSettlements( + settlement?.settlements || [], + { search, segment, categoryKey: catFilter, dayKey: dayFilter }, + me + ), + [settlement, search, segment, catFilter, dayFilter, me] + ); + const catBreakdown = useMemo(() => categoryBreakdown(budgetItems, ctx), [budgetItems, ctx]); + + // CSV export of all expenses, the wiki-documented export that got lost in the + // Costs rework (#1500). One row per expense, oldest first. + const exportCsv = useCallback(() => { + const { filename, content } = buildCostsCsv(budgetItems, { + base, + ctx, + locale, + tripTitle: trip?.title, + t, + currencyOf: csvCurrencyAsStored ? (e) => e.currency || tripCurrency : undefined, + }); + const blob = new Blob(['' + content], { type: 'text/csv;charset=utf-8;' }); + downloadBlob(blob, filename); + }, [budgetItems, base, ctx, locale, trip?.title, t, csvCurrencyAsStored, tripCurrency]); + + const deleteExpense = async (id: number) => { + try { + await deleteBudgetItem(tripId, id); + loadSettlement(); + } catch { + toast.error(t('common.unknownError')); + } + }; + + // Deleting the recorded transfer brings the suggested flow back, so it reads + // as "undo" rather than delete. + const undoSettlement = async (id: number) => { + try { + await budgetApi.deleteSettlement(tripId, id); + loadSettlement(); + } catch { + toast.error(t('common.unknownError')); + } + }; + + const settleFlow = async (fromId: number, toId: number, amount: number) => { + try { + await budgetApi.createSettlement( + tripId, + withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount, currency: base }, tripCurrency) + ); + loadSettlement(); + } catch { + toast.error(t('common.unknownError')); + } + }; + + const settleAll = async () => { + if (!flows.length) return; + try { + for (const f of flows) { + await budgetApi.createSettlement( + tripId, + withFallbackFx( + { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base }, + tripCurrency + ) + ); + } + } catch { + toast.error(t('common.unknownError')); + } finally { + // Refresh even when one transfer failed: the ones created before it are real, + // and leaving them in the flow list invites a second, doubled settle-up. + loadSettlement(); + } + }; + + return { + me, + base, + tripCurrency, + ctx, + settlement, + settlementError, + loadSettlement, + flows, + search, + setSearch, + segment, + setSegment, + catFilter, + setCatFilter, + dayFilter, + setDayFilter, + totals, + filtered, + filteredSettlements, + catBreakdown, + exportCsv, + deleteExpense, + undoSettlement, + settleFlow, + settleAll, + }; +} diff --git a/client/src/components/Budget/useSettlementForm.test.ts b/client/src/components/Budget/useSettlementForm.test.ts new file mode 100644 index 000000000..08d2e3d39 --- /dev/null +++ b/client/src/components/Budget/useSettlementForm.test.ts @@ -0,0 +1,221 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { budgetApi } from '../../api/client'; +import { clearExchangeRateCache } from '../../hooks/useExchangeRates'; +import { localToday } from '../Planner/today'; +import type { CostsSettlement } from './costsModel'; +import { useSettlementForm } from './useSettlementForm'; + +// FE-BUDGET-SETTLEFORM-001 to FE-BUDGET-SETTLEFORM-009 + +vi.mock('../../api/client', async (importOriginal) => ({ + ...(await importOriginal()), + budgetApi: { createSettlement: vi.fn(), updateSettlement: vi.fn() }, +})); + +const PEOPLE = [{ id: 1 }, { id: 2 }, { id: 3 }]; +const RECORDED: CostsSettlement = { + id: 30, + from_user_id: 2, + to_user_id: 3, + amount: 4.9, + currency: 'usd', + created_at: '2026-07-01T10:00:00Z', + settled_at: '2026-06-30', + note: 'cash', +}; + +let onSaved: Mock<() => void>; +let toastError: Mock<(message: string) => void>; + +interface Props { + editing: CostsSettlement | null; + open?: boolean; +} + +function setup(initial: Props, oneSaveAtATime?: boolean) { + return renderHook( + ({ editing, open }: Props) => + useSettlementForm({ + tripId: 7, + tripCurrency: 'EUR', + base: 'EUR', + people: PEOPLE, + me: 1, + editing, + open, + t: (key: string) => key, + toast: { error: toastError }, + onSaved, + oneSaveAtATime, + }), + { initialProps: initial } + ); +} + +const createMock = () => vi.mocked(budgetApi.createSettlement); +const updateMock = () => vi.mocked(budgetApi.updateSettlement); + +beforeEach(() => { + vi.clearAllMocks(); + clearExchangeRateCache(); + onSaved = vi.fn<() => void>(); + toastError = vi.fn<(message: string) => void>(); + createMock().mockResolvedValue({}); + updateMock().mockResolvedValue({}); +}); + +describe('useSettlementForm: where the desktop dialog starts', () => { + it('FE-BUDGET-SETTLEFORM-001: a new payment runs from me to the first other traveller, today, in the display currency', () => { + const { result } = setup({ editing: null }); + expect(result.current.fromId).toBe(1); + expect(result.current.toId).toBe(2); + expect(result.current.amount).toBe(''); + expect(result.current.currency).toBe('EUR'); + expect(result.current.day).toBe(localToday()); + expect(result.current.note).toBe(''); + expect(result.current.valid).toBe(false); + }); + + it('FE-BUDGET-SETTLEFORM-002: a reopened payment keeps its people, its currency decimals, its day and its note (#2175)', () => { + const { result } = setup({ editing: RECORDED }); + expect(result.current.fromId).toBe(2); + expect(result.current.toId).toBe(3); + expect(result.current.amount).toBe('4.90'); + expect(result.current.currency).toBe('USD'); + expect(result.current.day).toBe('2026-06-30'); + expect(result.current.note).toBe('cash'); + expect(result.current.valid).toBe(true); + }); +}); + +describe('useSettlementForm: where the phone sheet starts', () => { + it('FE-BUDGET-SETTLEFORM-003: the sheet starts blank while closed and fills in from the payment when it opens', () => { + const { result, rerender } = setup({ editing: RECORDED, open: false }); + expect(result.current.fromId).toBe(1); + expect(result.current.amount).toBe(''); + expect(result.current.currency).toBe('EUR'); + + rerender({ editing: RECORDED, open: true }); + expect(result.current.fromId).toBe(2); + expect(result.current.toId).toBe(3); + expect(result.current.amount).toBe('4.90'); + expect(result.current.currency).toBe('USD'); + expect(result.current.day).toBe('2026-06-30'); + expect(result.current.note).toBe('cash'); + }); + + it('FE-BUDGET-SETTLEFORM-004: reopening for a new payment starts over from the defaults', () => { + const { result, rerender } = setup({ editing: RECORDED, open: true }); + act(() => result.current.setNote('typed')); + rerender({ editing: null, open: false }); + rerender({ editing: null, open: true }); + expect(result.current.fromId).toBe(1); + expect(result.current.toId).toBe(2); + expect(result.current.amount).toBe(''); + expect(result.current.currency).toBe('EUR'); + expect(result.current.day).toBe(localToday()); + expect(result.current.note).toBe(''); + }); +}); + +describe('useSettlementForm: saving', () => { + it('FE-BUDGET-SETTLEFORM-005: records a new payment, reading a comma as the decimal mark, and trims the note', async () => { + const { result } = setup({ editing: null }); + act(() => { + result.current.setAmount('12,5'); + result.current.setNote(' dinner '); + result.current.setDay('2026-07-03'); + }); + expect(result.current.valid).toBe(true); + await act(async () => { + await result.current.save(); + }); + expect(createMock()).toHaveBeenCalledWith(7, { + from_user_id: 1, + to_user_id: 2, + amount: 12.5, + currency: 'EUR', + settled_at: '2026-07-03', + note: 'dinner', + }); + expect(onSaved).toHaveBeenCalledTimes(1); + expect(result.current.saving).toBe(false); + }); + + it('FE-BUDGET-SETTLEFORM-006: an edited payment is updated in place, an empty note goes as null', async () => { + const { result } = setup({ editing: RECORDED }); + act(() => result.current.setNote(' ')); + await act(async () => { + await result.current.save(); + }); + expect(updateMock()).toHaveBeenCalledWith(7, 30, { + from_user_id: 2, + to_user_id: 3, + amount: 4.9, + currency: 'USD', + settled_at: '2026-06-30', + note: null, + }); + expect(createMock()).not.toHaveBeenCalled(); + }); + + it('FE-BUDGET-SETTLEFORM-007: nothing is sent while the form is invalid', async () => { + const { result } = setup({ editing: null }); + act(() => { + result.current.setAmount('10'); + result.current.setToId(1); + }); + expect(result.current.valid).toBe(false); + await act(async () => { + await result.current.save(); + }); + act(() => { + result.current.setToId(2); + result.current.setDay(''); + }); + await act(async () => { + await result.current.save(); + }); + expect(createMock()).not.toHaveBeenCalled(); + }); + + it('FE-BUDGET-SETTLEFORM-008: a failed save toasts and leaves the form open', async () => { + createMock().mockRejectedValueOnce(new Error('nope')); + const { result } = setup({ editing: null }); + act(() => result.current.setAmount('3')); + await act(async () => { + await result.current.save(); + }); + expect(toastError).toHaveBeenCalledWith('common.unknownError'); + expect(onSaved).not.toHaveBeenCalled(); + expect(result.current.saving).toBe(false); + }); + + it('FE-BUDGET-SETTLEFORM-009: the phone ignores a second save while one is out, the desktop sends both', async () => { + const run = async (oneSaveAtATime: boolean) => { + createMock().mockClear(); + const pending: (() => void)[] = []; + createMock().mockImplementation(() => new Promise((resolve) => pending.push(() => resolve({})))); + const { result } = setup({ editing: null, open: true }, oneSaveAtATime); + act(() => result.current.setAmount('3')); + let first: Promise = Promise.resolve(); + act(() => { + first = result.current.save(); + }); + expect(result.current.saving).toBe(true); + await act(async () => { + void result.current.save(); + }); + const calls = createMock().mock.calls.length; + await act(async () => { + pending.forEach((resolve) => resolve()); + await first; + }); + return calls; + }; + expect(await run(true)).toBe(1); + expect(await run(false)).toBe(2); + }); +}); diff --git a/client/src/components/Budget/useSettlementForm.ts b/client/src/components/Budget/useSettlementForm.ts new file mode 100644 index 000000000..648c0b20e --- /dev/null +++ b/client/src/components/Budget/useSettlementForm.ts @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react'; + +import { budgetApi } from '../../api/client'; +import { withFallbackFx } from '../../hooks/useExchangeRates'; +import { amountToInputString } from '../../utils/formatters'; +import { localToday } from '../Planner/today'; +import { settlementDate } from './CostsPanel.helpers'; +import type { CostsSettlement } from './costsModel'; + +type Translate = (key: string, params?: Record) => string; +interface Toaster { + error: (message: string) => void; +} + +/** + * Recording or editing a settle-up payment, behind the desktop payment dialog and + * the phone payment sheet: who paid whom, how much in which currency, the day and + * a note. A transfer can be made in any currency (paying a rouble debt in euros is + * normal), so it carries its own, defaulting to the display currency `base`; a + * reopened one keeps the currency it was recorded in, and the server freezes its + * rate on write the way an expense's is frozen. + * + * The desktop dialog is mounted per payment and starts from `editing`. The phone + * sheet stays mounted and passes `open`: the form starts over from `editing` each + * time it opens, and a save while one is out is ignored (`oneSaveAtATime`). + */ +export function useSettlementForm({ + tripId, + tripCurrency, + base, + people, + me, + editing, + open, + t, + toast, + onSaved, + oneSaveAtATime = false, +}: { + tripId: number; + tripCurrency: string; + base: string; + people: { id: number }[]; + me: number; + editing: CostsSettlement | null; + open?: boolean; + t: Translate; + toast: Toaster; + onSaved: () => void; + oneSaveAtATime?: boolean; +}) { + // The phone sheet starts blank and fills in from `editing` once it opens. + const seed = open === undefined ? editing : null; + const [fromId, setFromId] = useState(() => seed?.from_user_id ?? me); + const [toId, setToId] = useState(() => seed?.to_user_id ?? people.find((p) => p.id !== me)?.id ?? me); + // Seeded with the transfer's own currency decimals, so a reopened 4,90 reads + // "4.90" and not "4.9" (#2175), and a JPY transfer gets no fake decimals. + const [amount, setAmount] = useState(() => + seed ? amountToInputString(seed.amount, (seed.currency || base).toUpperCase()) : '' + ); + const [currency, setCurrency] = useState(() => (seed?.currency || base).toUpperCase()); + const [day, setDay] = useState(() => (seed ? settlementDate(seed) : localToday())); + const [note, setNote] = useState(seed?.note || ''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) return; + const cur = (editing?.currency || base).toUpperCase(); + setFromId(editing?.from_user_id ?? me); + setToId(editing?.to_user_id ?? people.find((p) => p.id !== me)?.id ?? me); + setAmount(editing ? amountToInputString(editing.amount, cur) : ''); + setCurrency(cur); + setDay(editing ? settlementDate(editing) : localToday()); + setNote(editing?.note || ''); + setSaving(false); + }, [open, editing, me, base, people]); + + const amt = Number.parseFloat(amount.replace(',', '.')) || 0; + const valid = amt > 0 && fromId !== toId && !!day; + + const save = async () => { + if (!valid || (oneSaveAtATime && saving)) return; + setSaving(true); + const data = withFallbackFx( + { from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day, note: note.trim() || null }, + tripCurrency + ); + try { + if (editing) await budgetApi.updateSettlement(tripId, editing.id, data); + else await budgetApi.createSettlement(tripId, data); + onSaved(); + } catch { + toast.error(t('common.unknownError')); + } finally { + setSaving(false); + } + }; + + return { + fromId, + setFromId, + toId, + setToId, + amount, + setAmount, + currency, + setCurrency, + day, + setDay, + note, + setNote, + saving, + valid, + save, + }; +} diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index e8693fb40..2581df027 100644 --- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx @@ -11,7 +11,8 @@ import { useTripStore } from '../../../../store/tripStore' import { formatMoney, localizeAmountInput } from '../../../../utils/formatters' import { openFile } from '../../../../utils/fileDownload' import { splitShareLabel } from '../../../../components/Budget/expenseFx' -import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants' +import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants' +import { currencyOptions } from '../../../../components/Budget/costsModel' import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories' import { NOTE_MAX } from '../../../../components/Budget/CostsPanel.helpers' import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel' @@ -166,7 +167,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, onChange={v => setCurrency(String(v))} searchable size="sm" - options={currenciesWith(currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} + options={currencyOptions(currency)} style={{ width: '100%' }} />
diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 17cb49f01..0a3314bf3 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -1,26 +1,20 @@ -import { convertBooked, convertedLine } from '../../../../hooks/useExchangeRates' -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp, Layers, Pencil, Plus, RotateCcw, StickyNote, Trash2, Receipt, } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' -import { useAuthStore } from '../../../../store/authStore' -import { useSettingsStore } from '../../../../store/settingsStore' -import { useExchangeRates, withFallbackFx } from '../../../../hooks/useExchangeRates' import { useTranslation } from '../../../../i18n' -import { amountToInputString, formatMoney } from '../../../../utils/formatters' -import { downloadBlob, openFile } from '../../../../utils/fileDownload' -import { budgetApi } from '../../../../api/client' +import { formatMoney } from '../../../../utils/formatters' +import { openFile } from '../../../../utils/fileDownload' import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' -import { useFreezeMissingRates } from '../../../../components/Budget/useFreezeMissingRates' -import { finalBudgetFor, finalBudgetSources, NOTE_MAX, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' +import { useCostsLedger } from '../../../../components/Budget/useCostsLedger' +import { useSettlementForm } from '../../../../components/Budget/useSettlementForm' +import { finalBudgetFor, finalBudgetSources, NOTE_MAX, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import CustomSelect from '../../../../components/shared/CustomSelect' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' -import { SYMBOLS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants' -import { localToday } from '../../../../components/Planner/today' import MConfirmSheet from '../../settings/MConfirmSheet' import MSheet from '../../../components/MSheet' import MChip from '../../../components/MChip' @@ -28,10 +22,9 @@ import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } import { CountPill, TabScroller } from './tabChrome' import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { - baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals, - dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, lineOf, memberShareOf, tint, - type CostsCtx, type CostsSegment, type CostsSettlement, type CostsSettlementResponse, -} from './costsModel' + baseTotal, categoryFilterKeys, currencyOptions, dayFilterKeys, groupLedgerByDay, isUnfinished, lineOf, memberShareOf, + paymentAmount, paymentLineOf, tint, type CostsCtx, type CostsSettlement, +} from '../../../../components/Budget/costsModel' import type { BudgetParticipantFinal } from '@trek/shared' import type { BudgetItem, BudgetItemReceipt, TripMember } from '../../../../types' @@ -51,44 +44,16 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { const { t, tripId, trip, tripMembers, budgetItems, days, toast } = planner const { locale } = useTranslation() const canEdit = planner.can('budget_edit', trip) - const me = useAuthStore(s => s.user?.id ?? -1) - const displayCurrency = useSettingsStore(s => s.settings.default_currency) - const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() - const tripCurrency = (trip?.currency || base).toUpperCase() - // Anchored on the trip currency's quote, the one the server books with (#2525). - const { convert, displayPerTrip } = useExchangeRates(base, tripCurrency) - const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, displayCurrency: base, convert }), [me, tripCurrency, base, convert]) + // The display and trip currencies, the server-computed settlement, totals, + // filters and the ledger writes: the same ledger the desktop panel runs on. + const { + me, base, tripCurrency, ctx, settlement, settlementError, loadSettlement, flows, + search, setSearch, segment, setSegment, catFilter, setCatFilter, dayFilter, setDayFilter, + totals, filtered, filteredSettlements, catBreakdown, exportCsv: handleExportCsv, + deleteExpense, undoSettlement: handleUndoSettlement, + } = useCostsLedger({ tripId, trip, budgetItems, actions: planner.tripActions, canEdit, t, toast }) - const [settlement, setSettlement] = useState(null) - // A failed settlement read leaves `settlement` null, and the final budget would - // read that as "the trip cost nobody anything", a claim we cannot make. - const [settlementError, setSettlementError] = useState(false) - // Sends the browser's own figure for the display currency, as CostsPanel.tsx does. - const loadSettlement = useCallback(() => { - budgetApi.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null) - .then(s => { setSettlement(s); setSettlementError(false) }) - .catch(() => setSettlementError(true)) - }, [tripId, base, tripCurrency, displayPerTrip]) - - // Mirrors CostsPanel.tsx: items reload on trip change, settlement reloads on - // trip/base change and when the number of expenses changes; further refreshes - // are explicit after each mutation below (add/edit/delete expense, add - // payment), so an unrelated re-render doesn't refetch the settlement. The count - // is for an expense saved outside this tab: a scanned receipt is reviewed in - // the trip sheets, which reload the items but cannot reach this settlement. - useEffect(() => { - planner.tripActions.loadBudgetItems(tripId) - }, [tripId, planner.tripActions]) - useEffect(() => { - loadSettlement() - }, [budgetItems.length, loadSettlement]) - useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted: settlement?.unconverted, onHealed: loadSettlement }) - - const [search, setSearch] = useState('') - const [segment, setSegment] = useState('all') - const [catFilter, setCatFilter] = useState('') - const [dayFilter, setDayFilter] = useState('') const [catOpen, setCatOpen] = useState(false) const [dayOpen, setDayOpen] = useState(false) const [settleOpen, setSettleOpen] = useState(true) @@ -101,18 +66,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { const [confirmDelete, setConfirmDelete] = useState(null) const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null) - const flows = useMemo(() => settlement?.flows || [], [settlement]) - const totals = useMemo(() => computeTotals(budgetItems, flows, ctx), [budgetItems, flows, ctx]) - const filtered = useMemo( - () => filterBudgetItems(budgetItems, { search, segment, categoryKey: catFilter, dayKey: dayFilter }, ctx), - [budgetItems, search, segment, catFilter, dayFilter, ctx], - ) - const filteredSettlements = useMemo( - () => filterSettlements(settlement?.settlements || [], { search, segment, categoryKey: catFilter, dayKey: dayFilter }, me), - [settlement, search, segment, catFilter, dayFilter, me], - ) const groups = useMemo(() => groupLedgerByDay(filtered, filteredSettlements), [filtered, filteredSettlements]) - const catBreakdown = useMemo(() => categoryBreakdown(budgetItems, ctx), [budgetItems, ctx]) const catKeys = useMemo(() => categoryFilterKeys(budgetItems), [budgetItems]) const dayKeys = useMemo(() => dayFilterKeys(budgetItems), [budgetItems]) @@ -138,12 +92,6 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { } } - const handleExportCsv = useCallback(() => { - const { filename, content } = buildCostsCsv(budgetItems, { base, ctx, locale, tripTitle: trip?.title, t }) - const blob = new Blob(['' + content], { type: 'text/csv;charset=utf-8;' }) - downloadBlob(blob, filename) - }, [budgetItems, base, ctx, locale, trip?.title, t]) - // Header intent signals (spec 03 §3.8 addExpense/csvGo) — increment-only // counters the shell owns; pattern mirrors useTodoList.ts's addItemSignal. const lastAddSignal = useRef(shell.addExpenseSignal) @@ -163,25 +111,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { lastCsvSignal.current = shell.exportCostsCsvSignal }, [shell.exportCostsCsvSignal, handleExportCsv]) - const handleDeleteExpense = async (item: BudgetItem) => { - try { - await planner.tripActions.deleteBudgetItem(tripId, item.id) - loadSettlement() - } catch { - toast.error(t('common.unknownError')) - } - } - - // Mirrors CostsPanel.tsx's undoSettlement: deleting the recorded transfer - // brings the suggested flow back, so it reads as "undo" rather than delete. - const handleUndoSettlement = async (id: number) => { - try { - await budgetApi.deleteSettlement(tripId, id) - loadSettlement() - } catch { - toast.error(t('common.unknownError')) - } - } + const handleDeleteExpense = (item: BudgetItem) => deleteExpense(item.id) const handleTogglePaid = async (itemId: number, userId: number, paid: boolean) => { try { @@ -767,11 +697,10 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE onEdit: () => void onUndo: () => void }) { - const cur = (settlement.currency || base).toUpperCase() - // At the rate it was settled at, not today's (#1445), matching the desktop ledger. + // At the rate it was settled at, not today's (#1445), as on the desktop ledger. // A transfer without a currency was entered in the display currency, not the trip's. - const amount = convertBooked(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, ctx.convert) - const line = convertedLine(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, base, amount) + const amount = paymentAmount(settlement, ctx) + const line = paymentLineOf(settlement, ctx, amount) return (
@@ -883,8 +812,8 @@ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: { } /** - * "Add/edit payment" — records or updates a manual settle-up transfer - * (`budgetApi.createSettlement`/`updateSettlement`). No pixel spec exists for + * "Add/edit payment": records or updates a manual settle-up transfer through + * the same `useSettlementForm` as the desktop dialog. No pixel spec exists for * this form (the demo only toasts "Demo: add payment", 03-trip-tabs.md §3.8) * and no mobile/exported-desktop sheet covers it, so this is a small local * sheet built from the trip form-sheet chrome, kept to the fields the @@ -907,43 +836,8 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, p t: TFn onSaved: () => void }) { - const [fromId, setFromId] = useState(me) - const [toId, setToId] = useState(() => people.find(p => p.id !== me)?.id ?? me) - const [amount, setAmount] = useState('') - const [currency, setCurrency] = useState(base) - const [day, setDay] = useState(localToday()) - const [note, setNote] = useState('') - const [saving, setSaving] = useState(false) - - useEffect(() => { - if (!open) return - const cur = (editing?.currency || base).toUpperCase() - setFromId(editing?.from_user_id ?? me) - setToId(editing?.to_user_id ?? people.find(p => p.id !== me)?.id ?? me) - setAmount(editing ? amountToInputString(editing.amount, cur) : '') - setCurrency(cur) - setDay(editing ? settlementDate(editing) : localToday()) - setNote(editing?.note || '') - setSaving(false) - }, [open, editing, me, base, people]) - - const amt = Number.parseFloat(amount.replace(',', '.')) || 0 - const valid = amt > 0 && fromId !== toId && !!day - - const save = async () => { - if (!valid || saving) return - setSaving(true) - const data = withFallbackFx({ from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day, note: note.trim() || null }, tripCurrency) - try { - if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) - else await budgetApi.createSettlement(tripId, data) - onSaved() - } catch { - toast.error(t('common.unknownError')) - } finally { - setSaving(false) - } - } + const { fromId, setFromId, toId, setToId, amount, setAmount, currency, setCurrency, day, setDay, note, setNote, saving, valid, save } = + useSettlementForm({ tripId, tripCurrency, base, people, me, editing, open, t, toast, onSaved, oneSaveAtATime: true }) const title = editing ? t('costs.editPayment') : t('costs.addPayment') @@ -985,7 +879,7 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, p onChange={v => setCurrency(String(v))} searchable size="sm" - options={currenciesWith(currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} + options={currencyOptions(currency)} style={{ width: '100%' }} />
diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts deleted file mode 100644 index 0b1dd6eee..000000000 --- a/client/src/mobile/screens/trip/tabs/costsModel.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared' -import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers' -import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories' -import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates' -import { currencyDecimals } from '../../../../utils/formatters' -import type { BudgetItem } from '../../../../types' - -/** - * Costs view-model — the real-data counterpart to the demo's `EXPS`/`CATS` - * (spec 03 §3.10). Every function here mirrors the desktop `CostsPanel.tsx` - * maths 1:1 (baseTotal/myPaidOf/myShareOf/isUnfinished/totals/filters/CSV) so - * the two surfaces never disagree, decoupled from React so it stays testable. - * Money is converted to the display/base currency via an injected `convert` - * (from `useExchangeRates`), never computed here. - */ - -/** Per-render context every money computation needs — cheap to build, memoize in the caller. */ -export interface CostsCtx { - me: number - /** The trip's own currency — what a NULL `budget_items.currency` means. */ - tripCurrency: string - /** The currency everything is shown in, the base `convert` converts to. */ - displayCurrency: string - convert: (amount: number, currency: string | null | undefined) => number -} - -/** An expense's own currency, defaulting to the trip currency (NULL column = trip currency). */ -export function currencyOf(e: BudgetItem, ctx: CostsCtx): string { - return (e.currency || ctx.tripCurrency).toUpperCase() -} - -/** An amount of this expense in the display currency, at the rate the expense was booked at. */ -export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number { - return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert) -} - -/** - * The line under an amount of this expense the list shows converted, what was entered and - * where it went (#2525); null when there is nothing to explain. `shown` is the display - * value printed beside it. - */ -export function lineOf(amount: number, e: BudgetItem, ctx: CostsCtx, shown: number) { - return convertedLine(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.displayCurrency, shown) -} - -/** Expense total converted to the display/base currency, at its booked rate. */ -export function baseTotal(e: BudgetItem, ctx: CostsCtx): number { - return booked(e.total_price || 0, e, ctx) -} - -/** How much `ctx.me` personally fronted for this expense, in the base currency. */ -export function myPaidOf(e: BudgetItem, ctx: CostsCtx): number { - return booked(paidByUser(e, ctx.me), e, ctx) -} - -/** A given member's share of this expense (explicit custom amount, else equal split), base currency. */ -export function memberShareOf(e: BudgetItem, userId: number, ctx: CostsCtx): number { - const member = (e.members || []).find(m => m.user_id === userId) - if (!member) return 0 - if (member.amount !== null && member.amount !== undefined) { - return booked(member.amount, e, ctx) - } - const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id) - return booked(shares[userId] || 0, e, ctx) -} - -/** `ctx.me`'s own share — the common case of {@link memberShareOf}. */ -export function myShareOf(e: BudgetItem, ctx: CostsCtx): number { - return memberShareOf(e, ctx.me, ctx) -} - -/** - * A recorded total nobody has actually paid yet — counts toward the trip total - * but stays out of settlement. A negative total (a refund, #2176) is just as - * unfinished until its recipient is recorded as the (negative) payer. - */ -export function isUnfinished(e: BudgetItem, ctx: CostsCtx): boolean { - return baseTotal(e, ctx) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0 -} - -// ── settlement (server-computed; these types describe what MCostsTab reads from it) ── - -export interface CostsSettlementFlow { - from: { user_id: number; username: string; avatar_url?: string | null } - to: { user_id: number; username: string; avatar_url?: string | null } - amount: number -} - -export interface CostsBalance { - user_id: number - username: string - avatar_url: string | null - balance: number -} - -/** A recorded settle-up transfer ("Ajouter un paiement") — history, not a suggestion like {@link CostsSettlementFlow}. */ -export interface CostsSettlement { - id: number - from_user_id: number - to_user_id: number - amount: number - // Legacy rows predate this column (null) and are read as the display currency. - currency?: string | null - // The rate frozen when the transfer was settled, units of `currency` per 1 trip - // currency (#1445). Absent, or exactly 1, on rows written before the freeze existed. - exchange_rate?: number - created_at?: string - // The day the transfer actually happened; editable, unlike created_at (when it - // was recorded). Null/absent on rows predating this field. - settled_at?: string | null - /** A free-text note on the payment (#2340). */ - note?: string | null -} - -export interface CostsSettlementResponse { - balances: CostsBalance[] - flows: CostsSettlementFlow[] - settlements: CostsSettlement[] - /** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */ - finalBudgets: BudgetParticipantFinal[] - /** The currency the figures are in: the display currency, or the trip's own without a quote for it. */ - currency?: string - /** Rows no rate could convert, left out of every figure above. */ - unconverted?: BudgetUnconverted -} - -// ── hero / tile totals (spec §3.1-§3.3) ──────────────────────────────────── - -export interface CostsTotals { - totalSpend: number - myPaid: number - myShare: number - owe: number - owed: number - outstanding: number - outstandingCount: number -} - -export function computeTotals(items: BudgetItem[], flows: CostsSettlementFlow[], ctx: CostsCtx): CostsTotals { - const totalSpend = items.reduce((a, e) => a + baseTotal(e, ctx), 0) - const myPaid = items.reduce((a, e) => a + myPaidOf(e, ctx), 0) - const myShare = items.reduce((a, e) => a + myShareOf(e, ctx), 0) - const owe = flows.filter(f => f.from.user_id === ctx.me).reduce((a, f) => a + f.amount, 0) - const owed = flows.filter(f => f.to.user_id === ctx.me).reduce((a, f) => a + f.amount, 0) - const outstandingItems = items.filter(e => isUnfinished(e, ctx)) - const outstanding = outstandingItems.reduce((a, e) => a + baseTotal(e, ctx), 0) - return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount: outstandingItems.length } -} - -// ── expenses list: filter + group (spec §3.6-§3.7) ───────────────────────── - -export type CostsSegment = 'all' | 'mine' | 'owed' - -export interface CostsFilterState { - search: string - segment: CostsSegment - /** '' = all categories */ - categoryKey: string - /** '' = all days, else the expense's own YYYY-MM-DD */ - dayKey: string -} - -const round2 = (n: number) => Math.round(n * 100) / 100 - -/** - * `mine`/`owed` use the real balance, not the demo's `payer==='you'` shortcut - * (spec 03 §3.6): `mine` = I fronted money on it, `owed` = I'm net owed on it. - */ -export function filterBudgetItems(items: BudgetItem[], f: CostsFilterState, ctx: CostsCtx): BudgetItem[] { - let list = items.slice() - if (f.segment === 'mine') list = list.filter(e => myPaidOf(e, ctx) > 0) - if (f.segment === 'owed') list = list.filter(e => round2(myPaidOf(e, ctx) - myShareOf(e, ctx)) > 0) - if (f.categoryKey) list = list.filter(e => catMeta(e.category).key === f.categoryKey) - if (f.dayKey) list = list.filter(e => (e.expense_date || '') === f.dayKey) - const q = f.search.trim().toLowerCase() - if (q) list = list.filter(e => e.name.toLowerCase().includes(q)) - return list -} - -export interface CostsDayGroup { - /** '' = no date (spec's "NO DATE" group) */ - dateKey: string - items: BudgetItem[] -} - -/** Groups by `expense_date`, newest first, the no-date bucket sinking to the end (spec §3.7). */ -export function groupByDay(items: BudgetItem[]): CostsDayGroup[] { - const byDate = new Map() - for (const e of items) { - const key = e.expense_date || '' - const bucket = byDate.get(key) - if (bucket) bucket.push(e) - else byDate.set(key, [e]) - } - const keys = Array.from(byDate.keys()).sort((a, b) => { - if (a === b) return 0 - if (a === '') return 1 - if (b === '') return -1 - return b.localeCompare(a) - }) - return keys.map(dateKey => ({ dateKey, items: byDate.get(dateKey) as BudgetItem[] })) -} - -/** - * Settlements ("payments") shown inline in the ledger, mirroring the desktop - * `CostsPanel.tsx`'s `filteredSettlements`: they have no name/category, so a - * text or category filter hides them; "owed" excludes them; "mine" keeps only - * transfers the current user is part of. - */ -export function filterSettlements(settlements: CostsSettlement[], f: CostsFilterState, me: number): CostsSettlement[] { - if (f.search.trim() || f.categoryKey) return [] - if (f.segment === 'owed') return [] - let list = settlements.slice() - if (f.segment === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me) - if (f.dayKey) list = list.filter(s => settlementDate(s) === f.dayKey) - return list -} - -export type CostsLedgerEntry = - | { kind: 'expense'; date: string; item: BudgetItem } - | { kind: 'payment'; date: string; settlement: CostsSettlement } - -export interface CostsLedgerDayGroup { - /** '' = no date (spec's "NO DATE" group) */ - dateKey: string - entries: CostsLedgerEntry[] -} - -/** - * Like {@link groupByDay}, but also folds in settlement payments (see - * {@link filterSettlements}) as their own ledger entries, keyed by - * {@link settlementDate} — the mobile counterpart to desktop's unified - * `LedgerEntry` grouping, so a payment shows up even on a day with no expense. - */ -export function groupLedgerByDay(items: BudgetItem[], settlements: CostsSettlement[]): CostsLedgerDayGroup[] { - const entries: CostsLedgerEntry[] = [ - ...items.map(item => ({ kind: 'expense' as const, date: item.expense_date || '', item })), - ...settlements.map(settlement => ({ kind: 'payment' as const, date: settlementDate(settlement), settlement })), - ] - const byDate = new Map() - for (const en of entries) { - const bucket = byDate.get(en.date) - if (bucket) bucket.push(en) - else byDate.set(en.date, [en]) - } - const keys = Array.from(byDate.keys()).sort((a, b) => { - if (a === b) return 0 - if (a === '') return 1 - if (b === '') return -1 - return b.localeCompare(a) - }) - return keys.map(dateKey => ({ dateKey, entries: byDate.get(dateKey) as CostsLedgerEntry[] })) -} - -/** Categories present among `items`, canonical order — the dropdown only lists categories in use (spec §3.6). */ -export function categoryFilterKeys(items: BudgetItem[]): CostCategory[] { - const present = new Set(items.map(e => catMeta(e.category).key)) - return COST_CATEGORY_LIST.map(c => c.key).filter(k => present.has(k)) -} - -/** Distinct expense dates, ascending (spec §3.6: "Tage mit Ausgaben aufsteigend"). */ -export function dayFilterKeys(items: BudgetItem[]): string[] { - const dates = new Set(items.map(e => e.expense_date).filter((d): d is string => Boolean(d))) - return Array.from(dates).sort((a, b) => a.localeCompare(b)) -} - -// ── by-category breakdown (spec §3.5) ─────────────────────────────────────── - -export interface CostsCategoryBar { - key: CostCategory - amount: number - /** 0-100, relative to the largest category (not the grand total) — spec §3.5. */ - widthPct: number -} - -export function categoryBreakdown(items: BudgetItem[], ctx: CostsCtx): CostsCategoryBar[] { - // Categories net refunds against spend (#2176): a negative entry lowers its - // category's sum. A category that nets negative keeps its own row at the - // bottom, with widthPct 0 — the bars rank positive spend, and a negative - // CSS width would be dropped and render as a full bar. - const totals = new Map() - for (const e of items) { - const key = catMeta(e.category).key - totals.set(key, (totals.get(key) || 0) + baseTotal(e, ctx)) - } - const rows = COST_CATEGORY_LIST - .map(c => ({ key: c.key, amount: totals.get(c.key) || 0 })) - .filter(r => r.amount !== 0) - .sort((a, b) => b.amount - a.amount) - const max = Math.max(0, ...rows.map(r => r.amount)) - return rows.map(r => ({ ...r, widthPct: max > 0 && r.amount > 0 ? (r.amount / max) * 100 : 0 })) -} - -// ── presentation helpers ───────────────────────────────────────────────── - -/** Category colour at a fixed alpha — the expense card's border tint (spec §3.10: "brC"). */ -export function tint(hex: string, alpha: number): string { - const clean = hex.replace('#', '') - const r = Number.parseInt(clean.slice(0, 2), 16) - const g = Number.parseInt(clean.slice(2, 4), 16) - const b = Number.parseInt(clean.slice(4, 6), 16) - return `rgba(${r}, ${g}, ${b}, ${alpha})` -} - -// ── CSV export (spec §3.8 `csvGo`; string-building only — the caller turns this into a download) ── - -export interface CsvBuildOptions { - base: string - ctx: CostsCtx - locale: string - tripTitle?: string | null - t: (key: string) => string -} - -/** Ports `CostsPanel.tsx`'s `handleExportCsv` row-building 1:1; the Blob/download is a DOM concern left to the caller. */ -export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { filename: string; content: string } { - const sep = ';' - // A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel - // and Sheets, and the name/note columns are free text any trip member can write. - // Mirrors the same guard in `CostsPanel.tsx`'s exporter. - const esc = (v: unknown) => { - let s = String(v ?? '') - if (/^[=+\-@\t\r]/.test(s)) s = "'" + s - return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s - } - const fmtDate = (iso: string) => { - if (!iso) return '' - try { - return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) - } catch { - return iso - } - } - - // Read in another currency than the trip's, what each row counts as in the trip currency - // too, the figure every sum is built from (#2525). Same columns as the desktop export. - const trip = opts.ctx.tripCurrency.toUpperCase() - const tripCol = trip !== opts.base.toUpperCase() - const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? [`Amount (${trip})`] : []), `Amount (${opts.base})`, 'Note'] - const rows = [header.join(sep)] - const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')) - for (const e of sorted) { - const cur = currencyOf(e, opts.ctx) - const note = readUserNote(e) - const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, trip, opts.ctx.convert) - rows.push( - [ - esc(fmtDate(e.expense_date || '')), - esc(e.name), - esc(opts.t(catMeta(e.category).labelKey)), - (e.total_price || 0).toFixed(currencyDecimals(cur)), - cur, - ...(tripCol ? [inTrip.toFixed(currencyDecimals(trip))] : []), - baseTotal(e, opts.ctx).toFixed(currencyDecimals(opts.base)), - esc(note), - ].join(sep), - ) - } - - const safeName = (opts.tripTitle || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim() - return { filename: `costs-${safeName}.csv`, content: rows.join('\r\n') } -} diff --git a/client/tests/unit/mobile/trip/costsModel.test.ts b/client/tests/unit/mobile/trip/costsModel.test.ts index 9d5db7772..94feb7b50 100644 --- a/client/tests/unit/mobile/trip/costsModel.test.ts +++ b/client/tests/unit/mobile/trip/costsModel.test.ts @@ -21,7 +21,7 @@ import { type CostsFilterState, type CostsSettlement, type CostsSettlementFlow, -} from '../../../../src/mobile/screens/trip/tabs/costsModel'; +} from '../../../../src/components/Budget/costsModel'; import { buildBudgetItem } from '../../../helpers/factories'; import type { BudgetItem } from '../../../../src/types';