From 131d57b755897ef1041d2e1e6abed5d21c2bae3a Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 07:16:39 +0000 Subject: [PATCH] refactor(budget): share the costs ledger and settlement form between desktop and phone costsModel moves from the phone tabs to components/Budget. useCostsLedger holds the settlement, the totals, the filters, the CSV export and the payments, and useSettlementForm holds the payment form. CostsPanel and MCostsTab both run on them. The old differences stay behind flags. The desktop also loads the settlement on a trip change, leaves expenses nobody paid out of my share (#2225) and writes each currency as stored in the CSV. The phone sheet starts blank, refills when it opens and ignores a second save while one is running. --- client/src/components/Budget/CostsPanel.tsx | 314 +++---------- .../src/components/Budget/costsModel.test.ts | 137 ++++++ client/src/components/Budget/costsModel.ts | 428 ++++++++++++++++++ .../components/Budget/useCostsLedger.test.ts | 306 +++++++++++++ .../src/components/Budget/useCostsLedger.ts | 245 ++++++++++ .../Budget/useSettlementForm.test.ts | 221 +++++++++ .../components/Budget/useSettlementForm.ts | 116 +++++ .../mobile/screens/trip/sheets/MCostSheet.tsx | 5 +- .../mobile/screens/trip/tabs/MCostsTab.tsx | 158 ++----- .../mobile/screens/trip/tabs/costsModel.ts | 362 --------------- .../tests/unit/mobile/trip/costsModel.test.ts | 2 +- 11 files changed, 1539 insertions(+), 755 deletions(-) create mode 100644 client/src/components/Budget/costsModel.test.ts create mode 100644 client/src/components/Budget/costsModel.ts create mode 100644 client/src/components/Budget/useCostsLedger.test.ts create mode 100644 client/src/components/Budget/useCostsLedger.ts create mode 100644 client/src/components/Budget/useSettlementForm.test.ts create mode 100644 client/src/components/Budget/useSettlementForm.ts delete mode 100644 client/src/mobile/screens/trip/tabs/costsModel.ts 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';