diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 7bfa3d1df..d6dba4cd1 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -8,9 +8,8 @@ import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { budgetApi } from '../../api/client' -import { saveWithReceipts } from './receiptUploads' import { convertBooked, convertedLine, tripAmountOf, useExchangeRates, withFallbackFx } from '../../hooks/useExchangeRates' -import { splitShareLabel, useExpenseFx } from './expenseFx' +import { splitShareLabel } from './expenseFx' import { useFreezeMissingRates } from './useFreezeMissingRates' import { useIsMobile } from '../../hooks/useIsMobile' import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' @@ -21,9 +20,10 @@ import { localToday } from '../Planner/today' import { useReceiptScan } from './useReceiptScan' import { ReceiptScanModal } from './ReceiptScanModal' import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' -import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, newExpenseSeed, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' +import { finalBudgetFor, finalBudgetSources, NOTE_MAX, paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import { usePercentSplit, type CustomSplitUnit } from './usePercentSplit' +import { useExpenseForm } from './useExpenseForm' import { ReceiptPreviewModal } from './ReceiptPreviewModal' import type { BudgetParticipantFinal, BudgetUnconverted, ReceiptLine } from '@trek/shared' import type { BudgetItem, BudgetItemReceipt } from '../../types' @@ -1369,320 +1369,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void }) { const { t, locale } = useTranslation() - const toast = useToast() const isMobile = useIsMobile() const titleId = useId() - const { addBudgetItem, updateBudgetItem } = useTripStore() - const sym = (c: string) => SYMBOLS[c] || (c + ' ') - // A saved expense without a currency opens in the trip's own (#2525). - const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing) - - const [name, setName] = useState(editing?.name || prefill?.name || '') - const [cat, setCat] = useState(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) - const [seed] = useState(() => newExpenseSeed(prefill, base, people.map(p => p.id), localToday())) - const [currency, setCurrency] = useState(editing ? editingCurrency : seed.currency) - const [day, setDay] = useState(editing ? (editing.expense_date || localToday()) : seed.day) - const [note, setNote] = useState(() => readUserNote(editing)) - // Edit and prefill seeds are padded to the currency's decimals (#2175): the DB - // returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved - // 5,00 as "5". - const [total, setTotal] = useState(() => { - if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : '' - return seed.total - }) - const [participants, setParticipants] = useState>(() => - editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id))) - - // Payer state. An expense can be fronted by several people, each with their own - // amount (budget_item_payers) — a shared card, or "I got this round, you get the - // next". The single-payer dropdown stays the default path; multiPayer swaps in a - // per-person amount editor. 0 represents "Nobody (planning entry)"; on an - // existing expense a missing payer is a deliberate choice, so only a brand-new - // one defaults to me. A negative payer (the recipient of a refund, #2176) is a - // real payer — filtering on > 0 here would silently drop them on save. - const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0) - - const [payerId, setPayerId] = useState(() => { - const existingPayer = initialPayers[0] - if (existingPayer) return existingPayer.user_id - return editing ? 0 : me - }) - const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1) - const [payerIds, setPayerIds] = useState>(() => new Set(initialPayers.map(p => p.user_id))) - const [payerAmounts, setPayerAmounts] = useState>(() => { - const m: Record = {} - for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency) - return m - }) - // Payers the user typed an amount for: rebalance leaves these alone and makes - // the others absorb the remainder. - const [pinnedPayers, setPinnedPayers] = useState>(() => new Set(initialPayers.map(p => p.user_id))) - - const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => { - if (hasTicketSplit(editing)) { - return 'ticket' - } - if (editing && editing.members && editing.members.length > 0) { - const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined) - return hasCustom ? 'custom' : 'equally' - } - return 'equally' - }) - - const [ticketItems, setTicketItems] = useState(() => editing ? readTicketItems(editing) : seed.ticketItems) - - const [customAmounts, setCustomAmounts] = useState>(() => { - const m: Record = {} - if (editing && editing.members) { - for (const member of editing.members) { - if (member.amount !== null && member.amount !== undefined) { - m[member.user_id] = amountToInputString(member.amount, currency) - } - } - } - return m - }) - - const [receipts, setReceipts] = useState(() => editing?.receipts || []) - const [pendingReceiptFiles, setPendingReceiptFiles] = useState(() => editing ? [] : seed.receiptFiles) - const [uploadingReceipt, setUploadingReceipt] = useState(false) - const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null) - - const handleReceiptFileSelect = (files: FileList | File[] | null) => { - if (!files || files.length === 0) return - setPendingReceiptFiles(prev => [...prev, ...Array.from(files)]) - } - - const handleRemoveReceipt = (receiptId: number) => { - setReceipts(prev => prev.filter(r => r.id !== receiptId)) - } - - const handleRemovePendingReceipt = (index: number) => { - setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index)) - } - - const [saving, setSaving] = useState(false) - - const isTicketMode = splitMode === 'ticket' - - const ticketInfo = useMemo(() => { - return calculateTicketShares(ticketItems) - }, [ticketItems]) - - const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) - const fx = preview(totalNum, currency) - const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) - const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) - // How much is still to be handed out, read on the total's own side: on a refund - // (#2176) the shares run negative, so a plain total minus sum flips under and - // over around and sends the user the wrong way. - const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum - const each = participants.size > 0 ? totalNum / participants.size : 0 - const equalShares = useMemo(() => { - return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0) - }, [totalNum, participants, editing]) + const { + cat, currency, customAmounts, customBalanced, day, disableMultiPayer, each, enableMultiPayer, equalShares, fx, + handleAddEmptyItem, handleCustomAmountChange, handleReceiptFileSelect, handleRemoveItem, handleRemovePendingReceipt, + handleRemoveReceipt, handleToggleItemParticipant, handleUpdateItemName, handleUpdateItemPrice, isTicketMode, + multiPayer, name, nameOf, note, onPayerAmountChange, onTotalChange, participants, payerAmounts, payerId, payerIds, + payersOk, pendingReceiptFiles, placeholderShares, previewReceipts: modalPreviewReceipts, receipts, save, saving, + setCat, setCurrency, setCustomAmounts, setDay, setName, setNote, setPayerId, + setPreviewReceipts: setModalPreviewReceipts, setSplitMode, splitMode, splitShortfall, splitSum, sym, ticketInfo, + ticketItems, toggleParticipant, togglePayer, total, totalNum, tripCur, uploadingReceipt, valid, + } = useExpenseForm({ tripId, base, people, me, editing, prefill, onSaved, ticketShareMembers: true }) // The custom split typed as percentages instead of amounts (#1709). const pct = usePercentSplit({ total: totalNum, participants, customAmounts, setCustomAmounts, currency }) const inPercent = splitMode === 'custom' && pct.unit === 'percent' - const placeholderShares = useMemo(() => { - const emptyParts = [...participants].filter(id => !customAmounts[id]) - if (emptyParts.length === 0) return {} - - const enteredSum = [...participants] - .filter(id => customAmounts[id]) - .reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) - // Clamped toward zero on the total's own side, so an over-entered positive - // split never suggests negative leftovers — while a negative total (#2176) - // still previews its negative equal shares. - const rest = totalNum - enteredSum - const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest) - - return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0) - }, [totalNum, participants, customAmounts, editing]) - - const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0) - const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum)) - // A negative total is a valid entry (a refund, #2176); only zero has nothing to say. - const valid = name.trim().length > 0 && payersOk && ( - isTicketMode - ? ticketValid - : totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced) - ) - - const onTotalChange = (v: string) => { - setTotal(v.replace(',', '.')) - } - - // Keep the payer amounts summing to the total as it changes — including in ticket - // mode, where the total is derived from the ticket items rather than typed. - useEffect(() => { - if (!multiPayer) return - setPayerAmounts(prev => rebalancePayers(prev, pinnedPayers, payerIds, totalNum)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [totalNum]) - - const enableMultiPayer = () => { - const startPayers = payerIds.size > 0 ? new Set(payerIds) : new Set([payerId > 0 ? payerId : me]) - const pinned = new Set() - setPayerIds(startPayers) - setPinnedPayers(pinned) - setPayerAmounts(prev => rebalancePayers(prev, pinned, startPayers, totalNum)) - setMultiPayer(true) - } - - const disableMultiPayer = () => { - // Collapsing back keeps the first payer; their amount becomes the whole total. - const [first] = [...payerIds] - setPayerId(first ?? me) - setMultiPayer(false) - } - - const togglePayer = (id: number) => { - const nextIds = new Set(payerIds) - const nextPinned = new Set(pinnedPayers) - if (nextIds.has(id)) { - nextIds.delete(id) - nextPinned.delete(id) - } else { - nextIds.add(id) - } - setPayerIds(nextIds) - setPinnedPayers(nextPinned) - setPayerAmounts(prev => rebalancePayers(prev, nextPinned, nextIds, totalNum)) - } - - const onPayerAmountChange = (id: number, v: string) => { - const val = v.replace(',', '.') - const nextPinned = new Set(pinnedPayers) - nextPinned.add(id) - setPinnedPayers(nextPinned) - setPayerAmounts(prev => rebalancePayers({ ...prev, [id]: val }, nextPinned, payerIds, totalNum)) - } - - const handleCustomAmountChange = (id: number, val: string) => { - val = val.replace(',', '.') - if (val === '' || amountPattern(currency, true).test(val)) { - setCustomAmounts(prev => ({ ...prev, [id]: val })) - } - } - - const handleAddEmptyItem = () => { - setTicketItems(prev => [ - ...prev, - { - id: String(Date.now() + Math.random()), - name: '', - price: '', - participants: new Set(people.map(p => p.id)) - } - ]) - } - - const handleUpdateItemName = (id: string, name: string) => { - setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name } : item)) - } - - const handleUpdateItemPrice = (id: string, price: string) => { - price = price.replace(',', '.') - if (price === '' || amountPattern(currency, false).test(price)) { - setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item)) - } - } - - const handleRemoveItem = (id: string) => { - setTicketItems(prev => prev.filter(item => item.id !== id)) - } - - const handleToggleItemParticipant = (itemId: string, userId: number) => { - setTicketItems(prev => prev.map(item => { - if (item.id === itemId) { - const nextParts = new Set(item.participants) - if (nextParts.has(userId)) nextParts.delete(userId) - else nextParts.add(userId) - return { ...item, participants: nextParts } - } - return item - })) - } - - const toggleParticipant = (id: number) => { - const nextParts = new Set(participants) - if (nextParts.has(id)) { - nextParts.delete(id) - setCustomAmounts(prev => { - const copy = { ...prev } - delete copy[id] - return copy - }) - } else { - nextParts.add(id) - } - setParticipants(nextParts) - } - - const save = async () => { - if (!valid) return - setSaving(true) - // A picked payer always goes out, even when nobody shares the expense: the - // server re-derives total_price from the payer sum (CostsPanel.helpers), so - // dropping the payer would store the entry with a total of 0. - const payerList = multiPayer - ? [...payerIds] - .map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 })) - .filter(p => p.amount !== 0) - : payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : [] - // A receipt line can name somebody who is not ticked as a participant. Sending - // only the ticked set would drop their share, leaving the member sum short of - // total_price and handing the settlement a difference it can never clear (#1382). - const memberIds = splitMode === 'ticket' - ? [...new Set([...participants, ...Object.keys(ticketInfo.shares).map(Number)])].sort((a, b) => a - b) - : [...participants] - const memberList = memberIds.map(id => ({ - user_id: id, - amount: splitMode === 'custom' - ? (Number.parseFloat(customAmounts[id]) || 0) - : splitMode === 'ticket' - ? (ticketInfo.shares[id] || 0) - : null - })) - const data = { - name: name.trim(), - category: cat, - currency, - payers: payerList, - members: memberList, - member_ids: memberIds, - expense_date: day || null, - total_price: totalNum, - note: note.trim() || null, - ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null, - ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), - ...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}), - } - try { - setUploadingReceipt(pendingReceiptFiles.length > 0) - await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => ( - editing - ? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] }) - : addBudgetItem(tripId, { ...data, receipt_file_ids: ids }) - )) - // Only cleared once the save went through, so a retry after a failure - // does not upload a second copy of every file. - setPendingReceiptFiles([]) - onSaved() - } catch (err) { - // A receipt the rollback could not remove is still on the trip, and the - // user is the only one who can clear it out of the Files tab. - const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds - toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError')) - } finally { - setUploadingReceipt(false) - setSaving(false) - } - } - const catInfo = catMeta(cat) const money = (v: number) => formatMoney(v, currency, locale) // A person's row in the payer and split lists: avatar or initial, then the name. @@ -1697,7 +1400,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const AMOUNT_BOX = 'flex items-center gap-1 rounded-[8px] border border-edge bg-surface-input px-2.5' const AMOUNT_INPUT = 'w-full border-0 bg-transparent py-2 text-end font-semibold text-content outline-none dark:bg-transparent' - const nameOf = (p: TripMember) => (p.id === me ? t('costs.you') : p.username) // Who is in, shown as a real box to tick instead of a row that only fades. const tick = (on: boolean) => ( diff --git a/client/src/components/Budget/useExpenseForm.test.ts b/client/src/components/Budget/useExpenseForm.test.ts new file mode 100644 index 000000000..0e15a65c0 --- /dev/null +++ b/client/src/components/Budget/useExpenseForm.test.ts @@ -0,0 +1,552 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories'; +import { resetAllStores } from '../../../tests/helpers/store'; +import { clearExchangeRateCache } from '../../hooks/useExchangeRates'; +import { useTripStore, type TripStoreState } from '../../store/tripStore'; +import type { BudgetItem } from '../../types'; +import { localToday } from '../Planner/today'; +import type { TripMember } from './BudgetPanelMemberChips'; +import type { ExpensePrefill } from './CostsPanel'; +import { saveWithReceipts } from './receiptUploads'; +import { useExpenseForm } from './useExpenseForm'; + +vi.mock('./receiptUploads', () => ({ + saveWithReceipts: vi.fn( + async (_tripId: number, _files: File[], _id: number | null, save: (ids: number[]) => unknown) => ({ + result: await save([]), + stuckIds: [], + }) + ), +})); + +// FE-BUDGET-EXPFORM-001 to FE-BUDGET-EXPFORM-020 + +const PEOPLE: TripMember[] = [ + { id: 1, username: 'alice', avatar_url: null }, + { id: 2, username: 'bob', avatar_url: null }, +]; +const TODAY = localToday(); + +let addBudgetItem: ReturnType; +let updateBudgetItem: ReturnType; +let addToast: ReturnType; + +interface Options { + editing?: BudgetItem | null; + prefill?: ExpensePrefill; + ticketShareMembers?: boolean; + oneSaveAtATime?: boolean; + keepSavingOnSuccess?: boolean; +} + +function setup(options: Options = {}) { + const onSaved = vi.fn(); + const rendered = renderHook(() => + useExpenseForm({ + tripId: 7, + base: 'EUR', + people: PEOPLE, + me: 1, + editing: options.editing ?? null, + prefill: options.prefill, + onSaved, + ticketShareMembers: options.ticketShareMembers, + oneSaveAtATime: options.oneSaveAtATime, + keepSavingOnSuccess: options.keepSavingOnSuccess, + }) + ); + return { ...rendered, onSaved }; +} + +type Form = ReturnType['result']; + +function fill(result: Form, name: string, total: string) { + act(() => { + result.current.setName(name); + result.current.onTotalChange(total); + }); +} + +async function save(result: Form) { + await act(async () => { + await result.current.save(); + }); +} + +/** saveWithReceipts as it runs once the files are up: the save gets their ids. */ +const uploadsThenSave = (ids: number[]) => + (async (_tripId: number, _files: File[], _id: number | null, write: (uploaded: number[]) => Promise) => ({ + result: await write(ids), + stuckIds: [], + })) as unknown as typeof saveWithReceipts; + +const errorToasts = () => addToast.mock.calls.filter((c) => c[1] === 'error').map((c) => c[0]); + +beforeEach(() => { + resetAllStores(); + clearExchangeRateCache(); + // A fresh FX cache, so the hook never reaches the network. + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.25 }, ts: Date.now() })); + addBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 })); + updateBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 })); + useTripStore.setState({ + trip: buildTrip({ id: 7, currency: 'EUR' }), + addBudgetItem, + updateBudgetItem, + } as unknown as Partial); + addToast = vi.fn(); + (window as unknown as { __addToast: unknown }).__addToast = addToast; + vi.mocked(saveWithReceipts).mockClear(); +}); + +afterEach(() => { + delete (window as unknown as { __addToast?: unknown }).__addToast; +}); + +describe('useExpenseForm: where it starts', () => { + it('FE-BUDGET-EXPFORM-001: a new expense is food, in the display currency, today, split equally by everyone, paid by me', () => { + const { result } = setup(); + const f = result.current; + expect(f.name).toBe(''); + expect(f.cat).toBe('food'); + expect(f.currency).toBe('EUR'); + expect(f.day).toBe(TODAY); + expect(f.total).toBe(''); + expect([...f.participants]).toEqual([1, 2]); + expect(f.payerId).toBe(1); + expect(f.multiPayer).toBe(false); + expect(f.splitMode).toBe('equally'); + expect(f.receipts).toEqual([]); + expect(f.pendingReceiptFiles).toEqual([]); + expect(f.valid).toBe(false); + expect(f.saving).toBe(false); + expect(f.nameOf(PEOPLE[0])).toBe('costs.you'); + expect(f.nameOf(PEOPLE[1])).toBe('bob'); + expect(f.sym('EUR')).toBe('€'); + expect(f.sym('XYZ')).toBe('XYZ '); + }); + + it('FE-BUDGET-EXPFORM-002: a prefill brings its name, category, amount, currency, day, receipt lines and photos', () => { + const photo = new File(['x'], 'receipt.jpg'); + const { result } = setup({ + prefill: { + name: 'Cafe', + category: 'food', + amount: 12.5, + currency: 'usd', + date: '2026-05-01', + lines: [{ name: 'Coffee', price: 4 }], + receiptFiles: [photo], + }, + }); + const f = result.current; + expect(f.name).toBe('Cafe'); + expect(f.currency).toBe('USD'); + expect(f.total).toBe('12.50'); + expect(f.day).toBe('2026-05-01'); + expect(f.ticketItems.map((i) => [i.name, i.price, [...i.participants]])).toEqual([['Coffee', '4.00', [1, 2]]]); + // The lines wait in the Ticket split; the split itself stays Equally. + expect(f.splitMode).toBe('equally'); + expect(f.pendingReceiptFiles).toEqual([photo]); + }); + + it('FE-BUDGET-EXPFORM-003: an edited expense reopens with its payer, its custom split, its note and receipts', () => { + const receipt = { id: 3, filename: 'r.jpg', original_name: 'r.jpg', url: '/uploads/r.jpg' }; + const editing = buildBudgetItem({ + id: 9, + name: 'Museum', + category: 'activities', + total_price: 30, + currency: 'USD', + note: 'Adult tickets', + expense_date: '2026-04-30', + members: [ + { user_id: 1, paid: 0, username: 'alice', amount: 10 }, + { user_id: 2, paid: 0, username: 'bob', amount: 20 }, + ], + payers: [{ user_id: 2, amount: 30 }], + receipts: [receipt], + } as Partial); + const f = setup({ editing }).result.current; + expect(f.name).toBe('Museum'); + expect(f.cat).toBe('activities'); + expect(f.currency).toBe('USD'); + expect(f.total).toBe('30.00'); + expect(f.day).toBe('2026-04-30'); + expect(f.note).toBe('Adult tickets'); + expect(f.payerId).toBe(2); + expect(f.payerAmounts).toEqual({ 2: '30.00' }); + expect(f.splitMode).toBe('custom'); + expect(f.customAmounts).toEqual({ 1: '10.00', 2: '20.00' }); + expect(f.receipts).toEqual([receipt]); + expect(f.valid).toBe(true); + }); + + it('FE-BUDGET-EXPFORM-004: an edited expense nobody paid stays unpaid; several payers open the payer editor', () => { + const unpaid = setup({ editing: buildBudgetItem({ total_price: 20, payers: [], members: [] }) }).result.current; + expect(unpaid.payerId).toBe(0); + expect(unpaid.splitMode).toBe('equally'); + const shared = setup({ + editing: buildBudgetItem({ + total_price: 50, + currency: 'EUR', + payers: [ + { user_id: 1, amount: 20 }, + { user_id: 2, amount: 30 }, + ], + members: [{ user_id: 1, paid: 0, username: 'alice', amount: null }], + } as Partial), + }).result.current; + expect(shared.multiPayer).toBe(true); + expect([...shared.payerIds]).toEqual([1, 2]); + expect(shared.payerAmounts).toEqual({ 1: '20.00', 2: '30.00' }); + expect(shared.splitMode).toBe('equally'); + const ticket = setup({ + editing: buildBudgetItem({ + ticket_json: JSON.stringify({ items: [{ name: 'Pizza', price: '12', parts: [1] }] }), + }), + }).result.current; + expect(ticket.splitMode).toBe('ticket'); + expect(ticket.ticketItems.map((i) => i.name)).toEqual(['Pizza']); + }); +}); + +describe('useExpenseForm: what it adds up', () => { + it('FE-BUDGET-EXPFORM-005: equal shares, the custom sum and what is still to hand out', () => { + const { result } = setup(); + fill(result, 'Dinner', '40,5'); + expect(result.current.total).toBe('40.5'); + expect(result.current.totalNum).toBe(40.5); + expect(result.current.each).toBe(20.25); + expect(result.current.equalShares).toEqual({ 1: 20.25, 2: 20.25 }); + act(() => { + result.current.setSplitMode('custom'); + result.current.handleCustomAmountChange(1, '10,5'); + }); + expect(result.current.customAmounts).toEqual({ 1: '10.5' }); + expect(result.current.splitSum).toBe(10.5); + expect(result.current.splitShortfall).toBe(30); + expect(result.current.customBalanced).toBe(false); + expect(result.current.placeholderShares).toEqual({ 2: 30 }); + expect(result.current.valid).toBe(false); + act(() => result.current.handleCustomAmountChange(2, '30')); + expect(result.current.customBalanced).toBe(true); + expect(result.current.placeholderShares).toEqual({}); + expect(result.current.valid).toBe(true); + }); + + it('FE-BUDGET-EXPFORM-006: a refund splits negative, and an over-entered split suggests nothing below zero', () => { + const { result } = setup(); + fill(result, 'Refund', '-20'); + act(() => result.current.setSplitMode('custom')); + expect(result.current.placeholderShares).toEqual({ 1: -10, 2: -10 }); + act(() => result.current.handleCustomAmountChange(1, '-5')); + // Read on the refund's own side: 15 is still to go out, not 15 too much. + expect(result.current.splitShortfall).toBe(15); + const over = setup(); + fill(over.result, 'Taxi', '10'); + act(() => { + over.result.current.setSplitMode('custom'); + over.result.current.handleCustomAmountChange(1, '25'); + }); + expect(over.result.current.placeholderShares).toEqual({ 2: 0 }); + }); + + it('FE-BUDGET-EXPFORM-007: an entry needs a name and an amount that is not zero, a refund is fine', () => { + const { result } = setup(); + fill(result, ' ', '10'); + expect(result.current.valid).toBe(false); + fill(result, 'Taxi', '0'); + expect(result.current.valid).toBe(false); + fill(result, 'Taxi', '-10'); + expect(result.current.valid).toBe(true); + // With nobody in the split there is nothing to balance. + act(() => result.current.setSplitMode('custom')); + act(() => result.current.toggleParticipant(1)); + act(() => result.current.toggleParticipant(2)); + expect(result.current.participants.size).toBe(0); + expect(result.current.valid).toBe(true); + }); + + it('FE-BUDGET-EXPFORM-008: amounts are refused beyond the currency decimals and ticket prices cannot be negative', () => { + const { result } = setup(); + act(() => { + result.current.handleCustomAmountChange(1, '1.234'); + result.current.handleCustomAmountChange(2, 'abc'); + }); + expect(result.current.customAmounts).toEqual({}); + act(() => result.current.handleCustomAmountChange(1, '-2')); + expect(result.current.customAmounts).toEqual({ 1: '-2' }); + act(() => result.current.handleCustomAmountChange(1, '')); + expect(result.current.customAmounts).toEqual({ 1: '' }); + act(() => result.current.handleAddEmptyItem()); + const id = result.current.ticketItems[0].id; + act(() => result.current.handleUpdateItemPrice(id, '-3')); + expect(result.current.ticketItems[0].price).toBe(''); + act(() => result.current.handleUpdateItemPrice(id, '3,5')); + expect(result.current.ticketItems[0].price).toBe('3.5'); + }); +}); + +describe('useExpenseForm: payers', () => { + it('FE-BUDGET-EXPFORM-009: several payers start from the one picked and absorb what nobody typed', () => { + const { result } = setup(); + fill(result, 'Hotel', '30'); + act(() => result.current.enableMultiPayer()); + expect(result.current.multiPayer).toBe(true); + expect([...result.current.payerIds]).toEqual([1]); + expect(result.current.payerAmounts).toEqual({ 1: '30.00' }); + act(() => result.current.togglePayer(2)); + expect(result.current.payerAmounts).toEqual({ 1: '15.00', 2: '15.00' }); + act(() => result.current.onPayerAmountChange(1, '10')); + expect(result.current.payerAmounts).toEqual({ 1: '10', 2: '20.00' }); + expect(result.current.payersOk).toBe(true); + // A new total moves only the payer nobody typed for. + act(() => result.current.onTotalChange('50')); + expect(result.current.payerAmounts).toEqual({ 1: '10', 2: '40.00' }); + act(() => result.current.togglePayer(2)); + expect([...result.current.payerIds]).toEqual([1]); + expect(result.current.payerAmounts[1]).toBe('10'); + expect(result.current.payersOk).toBe(false); + expect(result.current.valid).toBe(false); + }); + + it('FE-BUDGET-EXPFORM-010: back to one payer keeps the first; a single payer is left alone by a new total', () => { + const { result } = setup(); + fill(result, 'Hotel', '30'); + act(() => result.current.setPayerId(0)); + act(() => result.current.enableMultiPayer()); + // "Nobody" picked: the editor starts on me. + expect([...result.current.payerIds]).toEqual([1]); + act(() => result.current.togglePayer(2)); + act(() => result.current.togglePayer(1)); + act(() => result.current.disableMultiPayer()); + expect(result.current.multiPayer).toBe(false); + expect(result.current.payerId).toBe(2); + const amounts = result.current.payerAmounts; + act(() => result.current.onTotalChange('80')); + expect(result.current.payerAmounts).toBe(amounts); + }); +}); + +describe('useExpenseForm: splits, ticket lines and receipts', () => { + it('FE-BUDGET-EXPFORM-011: ticket lines are added, named, priced, shared and removed', () => { + const { result } = setup(); + act(() => { + result.current.setSplitMode('ticket'); + result.current.handleAddEmptyItem(); + }); + expect(result.current.isTicketMode).toBe(true); + const [line] = result.current.ticketItems; + expect(line).toMatchObject({ name: '', price: '' }); + expect([...line.participants]).toEqual([1, 2]); + act(() => { + result.current.handleUpdateItemName(line.id, 'Pizza'); + result.current.handleUpdateItemPrice(line.id, '12'); + result.current.handleToggleItemParticipant(line.id, 2); + }); + expect(result.current.ticketInfo).toEqual({ shares: { 1: 12 }, total: 12 }); + expect(result.current.totalNum).toBe(12); + act(() => result.current.setName('Lunch')); + expect(result.current.valid).toBe(true); + act(() => result.current.handleToggleItemParticipant(line.id, 2)); + expect([...result.current.ticketItems[0].participants]).toEqual([1, 2]); + act(() => result.current.handleRemoveItem(line.id)); + expect(result.current.ticketItems).toEqual([]); + expect(result.current.valid).toBe(false); + }); + + it('FE-BUDGET-EXPFORM-012: leaving the split drops the custom amount, joining it again adds the person back', () => { + const { result } = setup(); + act(() => { + result.current.handleCustomAmountChange(2, '5'); + result.current.toggleParticipant(2); + }); + expect([...result.current.participants]).toEqual([1]); + expect(result.current.customAmounts).toEqual({}); + act(() => result.current.toggleParticipant(2)); + expect([...result.current.participants]).toEqual([1, 2]); + }); + + it('FE-BUDGET-EXPFORM-013: picked files wait to upload; saved and waiting receipts can both be taken off', () => { + const receipt = { id: 3, filename: 'r.jpg', original_name: 'r.jpg', url: '/uploads/r.jpg' }; + const { result } = setup({ editing: buildBudgetItem({ receipts: [receipt] }) }); + const a = new File(['a'], 'a.pdf'); + const b = new File(['b'], 'b.pdf'); + act(() => result.current.handleReceiptFileSelect(null)); + act(() => result.current.handleReceiptFileSelect([])); + expect(result.current.pendingReceiptFiles).toEqual([]); + act(() => result.current.handleReceiptFileSelect([a, b])); + expect(result.current.pendingReceiptFiles).toEqual([a, b]); + act(() => result.current.handleRemovePendingReceipt(0)); + expect(result.current.pendingReceiptFiles).toEqual([b]); + act(() => result.current.handleRemoveReceipt(3)); + expect(result.current.receipts).toEqual([]); + act(() => result.current.setPreviewReceipts({ receipts: [receipt], initialIndex: 0 })); + expect(result.current.previewReceipts).toEqual({ receipts: [receipt], initialIndex: 0 }); + }); +}); + +describe('useExpenseForm: saving', () => { + it('FE-BUDGET-EXPFORM-014: a new equal split goes out with me as payer and everyone in it', async () => { + const { result, onSaved } = setup(); + fill(result, ' Dinner ', '40'); + act(() => result.current.setNote(' with a view ')); + await save(result); + expect(addBudgetItem).toHaveBeenCalledWith(7, { + name: 'Dinner', + category: 'food', + currency: 'EUR', + payers: [{ user_id: 1, amount: 40 }], + members: [ + { user_id: 1, amount: null }, + { user_id: 2, amount: null }, + ], + member_ids: [1, 2], + expense_date: TODAY, + total_price: 40, + note: 'with a view', + ticket_json: null, + receipt_file_ids: [], + }); + expect(onSaved).toHaveBeenCalledTimes(1); + expect(result.current.saving).toBe(false); + expect(result.current.uploadingReceipt).toBe(false); + }); + + it('FE-BUDGET-EXPFORM-015: nobody paid sends no payer; a booking or a place links only a new expense', async () => { + const created = setup({ prefill: { name: 'Flight', amount: 100, reservationId: 4, placeId: 5 } }); + act(() => { + created.result.current.setPayerId(0); + created.result.current.setSplitMode('custom'); + created.result.current.handleCustomAmountChange(1, '60'); + created.result.current.handleCustomAmountChange(2, '40'); + }); + await save(created.result); + expect(addBudgetItem).toHaveBeenCalledWith( + 7, + expect.objectContaining({ + payers: [], + members: [ + { user_id: 1, amount: 60 }, + { user_id: 2, amount: 40 }, + ], + reservation_id: 4, + place_id: 5, + }) + ); + + const editing = buildBudgetItem({ id: 9, name: 'Flight', total_price: 100, payers: [{ user_id: 1, amount: 100 }] }); + const edited = setup({ editing, prefill: { reservationId: 4, placeId: 5 } }); + await save(edited.result); + const sent = updateBudgetItem.mock.calls[0][2]; + expect(updateBudgetItem).toHaveBeenCalledWith( + 7, + 9, + expect.objectContaining({ payers: [{ user_id: 1, amount: 100 }] }) + ); + expect(sent).not.toHaveProperty('reservation_id'); + expect(sent).not.toHaveProperty('place_id'); + }); + + it('FE-BUDGET-EXPFORM-016: an edit keeps the saved receipts and adds the uploaded ones', async () => { + vi.mocked(saveWithReceipts).mockImplementationOnce(uploadsThenSave([41])); + const receipt = { id: 3, filename: 'r.jpg', original_name: 'r.jpg', url: '/uploads/r.jpg' }; + const editing = buildBudgetItem({ id: 9, name: 'Museum', total_price: 30, receipts: [receipt] }); + const photo = new File(['x'], 'new.jpg'); + const { result } = setup({ editing }); + act(() => result.current.handleReceiptFileSelect([photo])); + await save(result); + expect(saveWithReceipts).toHaveBeenCalledWith(7, [photo], 9, expect.any(Function)); + expect(updateBudgetItem).toHaveBeenCalledWith(7, 9, expect.objectContaining({ receipt_file_ids: [3, 41] })); + expect(result.current.pendingReceiptFiles).toEqual([]); + }); + + it('FE-BUDGET-EXPFORM-017: a ticket split sends everyone a line names on the desktop, only the ticked on the phone (#1382)', async () => { + const run = async (ticketShareMembers: boolean) => { + const { result } = setup({ ticketShareMembers }); + act(() => { + result.current.setName('Lunch'); + result.current.setSplitMode('ticket'); + result.current.handleAddEmptyItem(); + }); + const id = result.current.ticketItems[0].id; + act(() => { + result.current.handleUpdateItemName(id, 'Pizza'); + result.current.handleUpdateItemPrice(id, '12'); + // Bob eats from the line but is not ticked for the expense. + result.current.toggleParticipant(2); + }); + await save(result); + return addBudgetItem.mock.calls[addBudgetItem.mock.calls.length - 1][1]; + }; + const desktop = await run(true); + expect(desktop).toMatchObject({ + members: [ + { user_id: 1, amount: 6 }, + { user_id: 2, amount: 6 }, + ], + member_ids: [1, 2], + total_price: 12, + ticket_json: JSON.stringify({ items: [{ name: 'Pizza', price: '12', parts: [1, 2] }] }), + }); + const phone = await run(false); + expect(phone).toMatchObject({ members: [{ user_id: 1, amount: 6 }], member_ids: [1] }); + }); + + it('FE-BUDGET-EXPFORM-018: a failed save says why; a receipt it could not take back is counted', async () => { + const { result, onSaved } = setup(); + fill(result, 'Dinner', '40'); + vi.mocked(saveWithReceipts).mockRejectedValueOnce(new Error('offline')); + await save(result); + vi.mocked(saveWithReceipts).mockRejectedValueOnce(Object.assign(new Error('x'), { stuckReceiptIds: [5, 6] })); + await save(result); + expect(errorToasts()).toEqual(['common.unknownError', 'costs.receiptLeftBehind']); + expect(onSaved).not.toHaveBeenCalled(); + expect(result.current.saving).toBe(false); + }); + + it('FE-BUDGET-EXPFORM-019: the phone stays busy after a save that went through and frees itself after a failure', async () => { + const { result } = setup({ oneSaveAtATime: true, keepSavingOnSuccess: true }); + fill(result, 'Dinner', '40'); + vi.mocked(saveWithReceipts).mockRejectedValueOnce(new Error('offline')); + await save(result); + expect(result.current.saving).toBe(false); + await save(result); + expect(addBudgetItem).toHaveBeenCalledTimes(1); + expect(result.current.saving).toBe(true); + }); + + it('FE-BUDGET-EXPFORM-020: a save while one is out is dropped on the phone and sent again on the desktop; an invalid one never', async () => { + const pending = (() => new Promise(() => {})) as unknown as typeof saveWithReceipts; + vi.mocked(saveWithReceipts) + .mockImplementationOnce(pending) + .mockImplementationOnce(pending) + .mockImplementationOnce(pending); + const phone = setup({ oneSaveAtATime: true }); + fill(phone.result, 'Dinner', '40'); + act(() => { + void phone.result.current.save(); + }); + act(() => { + void phone.result.current.save(); + }); + expect(saveWithReceipts).toHaveBeenCalledTimes(1); + + const desktop = setup(); + fill(desktop.result, 'Dinner', '40'); + act(() => { + void desktop.result.current.save(); + }); + act(() => { + void desktop.result.current.save(); + }); + expect(saveWithReceipts).toHaveBeenCalledTimes(3); + + const invalid = setup(); + await save(invalid.result); + expect(saveWithReceipts).toHaveBeenCalledTimes(3); + }); +}); diff --git a/client/src/components/Budget/useExpenseForm.ts b/client/src/components/Budget/useExpenseForm.ts new file mode 100644 index 000000000..4e6920364 --- /dev/null +++ b/client/src/components/Budget/useExpenseForm.ts @@ -0,0 +1,462 @@ +import { useEffect, useEffectEvent, useMemo, useState } from 'react'; + +import { useTranslation } from '../../i18n'; +import { useTripStore } from '../../store/tripStore'; +import type { BudgetItem, BudgetItemReceipt } from '../../types'; +import { amountToInputString } from '../../utils/formatters'; +import { localToday } from '../Planner/today'; +import { useToast } from '../shared/Toast'; +import { SYMBOLS } from './BudgetPanel.constants'; +import type { TripMember } from './BudgetPanelMemberChips'; +import type { ExpensePrefill } from './CostsPanel'; +import { + amountPattern, + calculateTicketShares, + hasTicketSplit, + newExpenseSeed, + payersBalanced, + readTicketItems, + readUserNote, + rebalancePayers, + splitEqualShares, + writeTicketItems, + type TicketItem, +} from './CostsPanel.helpers'; +import { catMeta } from './costsCategories'; +import { useExpenseFx } from './expenseFx'; +import { saveWithReceipts } from './receiptUploads'; + +export type ExpenseSplitMode = 'equally' | 'custom' | 'ticket'; + +/** Keeps the local ids of ticket lines added in the same millisecond apart. */ +let ticketItemSeq = 0; + +/** + * The expense editor behind the desktop ExpenseModal and the phone MCostSheet: + * name, category, amount and currency, payers, the Equally / Custom / Ticket + * splits, the note and the receipts, what makes the entry valid, and the save + * through the trip store. Both surfaces lay it out their own way. + * + * Where the two save differently today, the surface says which way: + * `ticketShareMembers` (desktop) also sends everyone a ticket line names (#1382); + * `oneSaveAtATime` (phone) ignores a save while one is out; `keepSavingOnSuccess` + * (phone) leaves the form busy after a save that went through, as the sheet closes. + */ +export function useExpenseForm({ + tripId, + base, + people, + me, + editing, + prefill, + onSaved, + ticketShareMembers = false, + oneSaveAtATime = false, + keepSavingOnSuccess = false, +}: { + tripId: number; + base: string; + people: TripMember[]; + me: number; + editing: BudgetItem | null; + prefill?: ExpensePrefill; + onSaved: () => void; + ticketShareMembers?: boolean; + oneSaveAtATime?: boolean; + keepSavingOnSuccess?: boolean; +}) { + const { t } = useTranslation(); + const toast = useToast(); + const addBudgetItem = useTripStore((s) => s.addBudgetItem); + const updateBudgetItem = useTripStore((s) => s.updateBudgetItem); + const sym = (c: string) => SYMBOLS[c] || c + ' '; + const nameOf = (p: TripMember) => (p.id === me ? t('costs.you') : p.username); + // A saved expense without a currency opens in the trip's own (#2525). + const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing); + + const [name, setName] = useState(editing?.name || prefill?.name || ''); + const [cat, setCat] = useState(editing ? catMeta(editing.category).key : prefill?.category || 'food'); + const [seed] = useState(() => + newExpenseSeed( + prefill, + base, + people.map((p) => p.id), + localToday() + ) + ); + const [currency, setCurrency] = useState(editing ? editingCurrency : seed.currency); + const [day, setDay] = useState(editing ? editing.expense_date || localToday() : seed.day); + const [note, setNote] = useState(() => readUserNote(editing)); + // Edit and prefill seeds are padded to the currency's decimals (#2175): the DB + // returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved + // 5,00 as "5". + const [total, setTotal] = useState(() => { + if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : ''; + return seed.total; + }); + const [participants, setParticipants] = useState>(() => + editing ? new Set((editing.members || []).map((m) => m.user_id)) : new Set(people.map((p) => p.id)) + ); + + // Payer state. An expense can be fronted by several people, each with their own + // amount (budget_item_payers): a shared card, or "I got this round, you get the + // next". The single-payer choice stays the default path; multiPayer swaps in a + // per-person amount editor. 0 represents "Nobody (planning entry)"; on an + // existing expense a missing payer is a deliberate choice, so only a brand-new + // one defaults to me. A negative payer (the recipient of a refund, #2176) is a + // real payer: filtering on > 0 here would silently drop them on save. + const initialPayers = (editing?.payers || []).filter((p) => p.amount !== 0); + + const [payerId, setPayerId] = useState(() => { + const existingPayer = initialPayers[0]; + if (existingPayer) return existingPayer.user_id; + return editing ? 0 : me; + }); + const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1); + const [payerIds, setPayerIds] = useState>(() => new Set(initialPayers.map((p) => p.user_id))); + const [payerAmounts, setPayerAmounts] = useState>(() => { + const m: Record = {}; + for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency); + return m; + }); + // Payers the user typed an amount for: rebalance leaves these alone and makes + // the others absorb the remainder. + const [pinnedPayers, setPinnedPayers] = useState>(() => new Set(initialPayers.map((p) => p.user_id))); + + const [splitMode, setSplitMode] = useState(() => { + if (hasTicketSplit(editing)) return 'ticket'; + if (editing && editing.members && editing.members.length > 0) { + const hasCustom = editing.members.some((m) => m.amount !== null && m.amount !== undefined); + return hasCustom ? 'custom' : 'equally'; + } + return 'equally'; + }); + + const [ticketItems, setTicketItems] = useState(() => + editing ? readTicketItems(editing) : seed.ticketItems + ); + + const [customAmounts, setCustomAmounts] = useState>(() => { + const m: Record = {}; + if (editing && editing.members) { + for (const member of editing.members) { + if (member.amount !== null && member.amount !== undefined) { + m[member.user_id] = amountToInputString(member.amount, currency); + } + } + } + return m; + }); + + const [receipts, setReceipts] = useState(() => editing?.receipts || []); + const [pendingReceiptFiles, setPendingReceiptFiles] = useState(() => (editing ? [] : seed.receiptFiles)); + const [uploadingReceipt, setUploadingReceipt] = useState(false); + const [previewReceipts, setPreviewReceipts] = useState<{ + receipts: BudgetItemReceipt[]; + initialIndex: number; + } | null>(null); + + const handleReceiptFileSelect = (files: FileList | File[] | null) => { + if (!files || files.length === 0) return; + setPendingReceiptFiles((prev) => [...prev, ...Array.from(files)]); + }; + + const handleRemoveReceipt = (receiptId: number) => { + setReceipts((prev) => prev.filter((r) => r.id !== receiptId)); + }; + + const handleRemovePendingReceipt = (index: number) => { + setPendingReceiptFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + const [saving, setSaving] = useState(false); + + const isTicketMode = splitMode === 'ticket'; + const ticketInfo = useMemo(() => calculateTicketShares(ticketItems), [ticketItems]); + + const totalNum = isTicketMode ? ticketInfo.total : Number.parseFloat(total) || 0; + const fx = preview(totalNum, currency); + const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0); + const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100); + // How much is still to be handed out, read on the total's own side: on a refund + // (#2176) the shares run negative, so a plain total minus sum flips under and + // over around and sends the user the wrong way. + const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum; + const each = participants.size > 0 ? totalNum / participants.size : 0; + const equalShares = useMemo( + () => + splitEqualShares( + totalNum, + [...participants].map((id) => ({ user_id: id })), + editing?.id || 0 + ), + [totalNum, participants, editing] + ); + + const placeholderShares = useMemo(() => { + const emptyParts = [...participants].filter((id) => !customAmounts[id]); + if (emptyParts.length === 0) return {}; + const enteredSum = [...participants] + .filter((id) => customAmounts[id]) + .reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0); + // Clamped toward zero on the total's own side, so an over-entered positive + // split never suggests negative leftovers, while a negative total (#2176) + // still previews its negative equal shares. + const rest = totalNum - enteredSum; + const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest); + return splitEqualShares( + remaining, + emptyParts.map((id) => ({ user_id: id })), + editing?.id || 0 + ); + }, [totalNum, participants, customAmounts, editing]); + + const ticketValid = + ticketItems.length > 0 && + ticketItems.every( + (item) => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0 + ); + const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum)); + // A negative total is a valid entry (a refund, #2176); only zero has nothing to say. + const valid = + name.trim().length > 0 && + payersOk && + (isTicketMode + ? ticketValid + : totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)); + + const onTotalChange = (v: string) => setTotal(v.replace(',', '.')); + + // Keep the payer amounts summing to the total as it changes, including in ticket + // mode, where the total is derived from the ticket items rather than typed. Only + // a new total triggers it; the payer state is read as it stands then. + const rebalanceToTotal = useEffectEvent(() => { + if (!multiPayer) return; + setPayerAmounts((prev) => rebalancePayers(prev, pinnedPayers, payerIds, totalNum)); + }); + useEffect(() => { + rebalanceToTotal(); + }, [totalNum]); + + const enableMultiPayer = () => { + const startPayers = payerIds.size > 0 ? new Set(payerIds) : new Set([payerId > 0 ? payerId : me]); + const pinned = new Set(); + setPayerIds(startPayers); + setPinnedPayers(pinned); + setPayerAmounts((prev) => rebalancePayers(prev, pinned, startPayers, totalNum)); + setMultiPayer(true); + }; + + const disableMultiPayer = () => { + // Collapsing back keeps the first payer; their amount becomes the whole total. + const [first] = [...payerIds]; + setPayerId(first ?? me); + setMultiPayer(false); + }; + + const togglePayer = (id: number) => { + const nextIds = new Set(payerIds); + const nextPinned = new Set(pinnedPayers); + if (nextIds.has(id)) { + nextIds.delete(id); + nextPinned.delete(id); + } else { + nextIds.add(id); + } + setPayerIds(nextIds); + setPinnedPayers(nextPinned); + setPayerAmounts((prev) => rebalancePayers(prev, nextPinned, nextIds, totalNum)); + }; + + const onPayerAmountChange = (id: number, v: string) => { + const val = v.replace(',', '.'); + const nextPinned = new Set(pinnedPayers); + nextPinned.add(id); + setPinnedPayers(nextPinned); + setPayerAmounts((prev) => rebalancePayers({ ...prev, [id]: val }, nextPinned, payerIds, totalNum)); + }; + + const handleCustomAmountChange = (id: number, raw: string) => { + const val = raw.replace(',', '.'); + if (val === '' || amountPattern(currency, true).test(val)) { + setCustomAmounts((prev) => ({ ...prev, [id]: val })); + } + }; + + const handleAddEmptyItem = () => { + setTicketItems((prev) => [ + ...prev, + { id: `${Date.now()}-${++ticketItemSeq}`, name: '', price: '', participants: new Set(people.map((p) => p.id)) }, + ]); + }; + + const handleUpdateItemName = (id: string, itemName: string) => { + setTicketItems((prev) => prev.map((item) => (item.id === id ? { ...item, name: itemName } : item))); + }; + + const handleUpdateItemPrice = (id: string, raw: string) => { + const price = raw.replace(',', '.'); + if (price === '' || amountPattern(currency, false).test(price)) { + setTicketItems((prev) => prev.map((item) => (item.id === id ? { ...item, price } : item))); + } + }; + + const handleRemoveItem = (id: string) => { + setTicketItems((prev) => prev.filter((item) => item.id !== id)); + }; + + const handleToggleItemParticipant = (itemId: string, userId: number) => { + setTicketItems((prev) => + prev.map((item) => { + if (item.id !== itemId) return item; + const nextParts = new Set(item.participants); + if (nextParts.has(userId)) nextParts.delete(userId); + else nextParts.add(userId); + return { ...item, participants: nextParts }; + }) + ); + }; + + const toggleParticipant = (id: number) => { + const nextParts = new Set(participants); + if (nextParts.has(id)) { + nextParts.delete(id); + setCustomAmounts((prev) => { + const copy = { ...prev }; + delete copy[id]; + return copy; + }); + } else { + nextParts.add(id); + } + setParticipants(nextParts); + }; + + const save = async () => { + if (!valid || (oneSaveAtATime && saving)) return; + setSaving(true); + // A picked payer always goes out, even when nobody shares the expense: the + // server re-derives total_price from the payer sum (CostsPanel.helpers), so + // dropping the payer would store the entry with a total of 0. + const payerList = multiPayer + ? [...payerIds] + .map((id) => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 })) + .filter((p) => p.amount !== 0) + : payerId > 0 + ? [{ user_id: payerId, amount: totalNum }] + : []; + // A receipt line can name somebody who is not ticked as a participant. Sending + // only the ticked set would drop their share, leaving the member sum short of + // total_price and handing the settlement a difference it can never clear (#1382). + const memberIds = + splitMode === 'ticket' && ticketShareMembers + ? [...new Set([...participants, ...Object.keys(ticketInfo.shares).map(Number)])].sort((a, b) => a - b) + : [...participants]; + const memberList = memberIds.map((id) => ({ + user_id: id, + amount: + splitMode === 'custom' + ? Number.parseFloat(customAmounts[id]) || 0 + : splitMode === 'ticket' + ? ticketInfo.shares[id] || 0 + : null, + })); + const data = { + name: name.trim(), + category: cat, + currency, + payers: payerList, + members: memberList, + member_ids: memberIds, + expense_date: day || null, + total_price: totalNum, + note: note.trim() || null, + ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null, + ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), + ...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}), + }; + try { + setUploadingReceipt(pendingReceiptFiles.length > 0); + await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, (ids) => + editing + ? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map((r) => r.id), ...ids] }) + : addBudgetItem(tripId, { ...data, receipt_file_ids: ids }) + ); + // Only cleared once the save went through, so a retry after a failure + // does not upload a second copy of every file. + setPendingReceiptFiles([]); + onSaved(); + } catch (err) { + // A receipt the rollback could not remove is still on the trip, and the + // user is the only one who can clear it out of the Files tab. + const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds; + toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError')); + if (keepSavingOnSuccess) setSaving(false); + } finally { + setUploadingReceipt(false); + if (!keepSavingOnSuccess) setSaving(false); + } + }; + + return { + sym, + nameOf, + tripCur, + name, + setName, + cat, + setCat, + currency, + setCurrency, + day, + setDay, + note, + setNote, + total, + onTotalChange, + totalNum, + fx, + participants, + toggleParticipant, + payerId, + setPayerId, + multiPayer, + enableMultiPayer, + disableMultiPayer, + payerIds, + payerAmounts, + togglePayer, + onPayerAmountChange, + payersOk, + splitMode, + setSplitMode, + isTicketMode, + ticketItems, + ticketInfo, + handleAddEmptyItem, + handleUpdateItemName, + handleUpdateItemPrice, + handleRemoveItem, + handleToggleItemParticipant, + customAmounts, + setCustomAmounts, + handleCustomAmountChange, + splitSum, + customBalanced, + splitShortfall, + each, + equalShares, + placeholderShares, + receipts, + pendingReceiptFiles, + uploadingReceipt, + handleReceiptFileSelect, + handleRemoveReceipt, + handleRemovePendingReceipt, + previewReceipts, + setPreviewReceipts, + valid, + saving, + save, + }; +} diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index 1ccfb1896..e8693fb40 100644 --- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Check, ChevronDown, Plus, Trash2, Wallet, Receipt, Paperclip } from 'lucide-react' import MSheet from '../../../components/MSheet' import CustomSelect from '../../../../components/shared/CustomSelect' @@ -8,20 +8,18 @@ import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } import { useTranslation } from '../../../../i18n' import { useToast } from '../../../../components/shared/Toast' import { useTripStore } from '../../../../store/tripStore' -import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters' +import { formatMoney, localizeAmountInput } from '../../../../utils/formatters' import { openFile } from '../../../../utils/fileDownload' -import { saveWithReceipts } from '../../../../components/Budget/receiptUploads' -import { splitShareLabel, useExpenseFx } from '../../../../components/Budget/expenseFx' +import { splitShareLabel } from '../../../../components/Budget/expenseFx' import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants' import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories' -import { localToday } from '../../../../components/Planner/today' -import { amountPattern, calculateTicketShares, hasTicketSplit, newExpenseSeed, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers' +import { NOTE_MAX } from '../../../../components/Budget/CostsPanel.helpers' import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel' -import { payersBalanced, rebalancePayers } from '../../../../components/Budget/CostsPanel.helpers' +import { useExpenseForm } from '../../../../components/Budget/useExpenseForm' import GuestBadge from '../../../../components/shared/GuestBadge' import type { TripMember } from '../../../../components/Budget/BudgetPanelMemberChips' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' -import type { BudgetItem, BudgetItemReceipt } from '../../../../types' +import type { BudgetItem } from '../../../../types' export interface MCostSheetProps { tripId: number @@ -57,10 +55,19 @@ const SPLIT_MODES = [ export default function MCostSheet({ tripId, base, people, me, editing, prefill, onClose, onSaved }: MCostSheetProps) { const { t, locale } = useTranslation() const toast = useToast() - const { addBudgetItem, updateBudgetItem, deleteBudgetItem } = useTripStore() - const sym = (c: string) => SYMBOLS[c] || (c + ' ') - // A saved expense without a currency opens in the trip's own (#2525), as on desktop. - const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing) + const { deleteBudgetItem } = useTripStore() + + // The same editor as the desktop modal, a saved expense without a currency + // opening in the trip's own (#2525). + const { + sym, nameOf, tripCur, name, setName, cat, setCat, currency, setCurrency, day, setDay, note, setNote, total, onTotalChange, + totalNum, fx, participants, toggleParticipant, payerId, setPayerId, multiPayer, enableMultiPayer, disableMultiPayer, + payerIds, payerAmounts, togglePayer, onPayerAmountChange, payersOk, splitMode, setSplitMode, isTicketMode, ticketItems, + ticketInfo, handleAddEmptyItem, handleUpdateItemName, handleUpdateItemPrice, handleRemoveItem, handleToggleItemParticipant, + customAmounts, handleCustomAmountChange, splitSum, customBalanced, each, equalShares, placeholderShares, + receipts, pendingReceiptFiles, uploadingReceipt, handleReceiptFileSelect, handleRemoveReceipt, handleRemovePendingReceipt, + previewReceipts, setPreviewReceipts, valid, saving, save, + } = useExpenseForm({ tripId, base, people, me, editing, prefill, onSaved, oneSaveAtATime: true, keepSavingOnSuccess: true }) // Internal open flag so the exit animation still plays even though the parent // unmounts us on close. @@ -72,243 +79,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, closeTimer.current = window.setTimeout(onClose, 280) } - const [name, setName] = useState(editing?.name || prefill?.name || '') - const [cat, setCat] = useState(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) const [catOpen, setCatOpen] = useState(false) - const [note, setNote] = useState(() => readUserNote(editing)) - const [seed] = useState(() => newExpenseSeed(prefill, base, people.map(p => p.id), localToday())) - const [currency, setCurrency] = useState(editing ? editingCurrency : seed.currency) - const [day, setDay] = useState(editing ? (editing.expense_date || localToday()) : seed.day) - // Edit and prefill seeds are padded to the currency's decimals (#2175), same - // as the desktop modal: a saved 4,90 must reopen as "4,90", not "4,9". - const [total, setTotal] = useState(() => { - if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : '' - return seed.total - }) - const [participants, setParticipants] = useState>(() => - editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id))) - - // Payer state — same model as the desktop modal. 0 = "Nobody (planning entry)". - // A negative payer (the recipient of a refund, #2176) must survive the reopen. - const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0) - const [payerId, setPayerId] = useState(() => { - const existingPayer = initialPayers[0] - if (existingPayer) return existingPayer.user_id - return editing ? 0 : me - }) - const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1) - const [payerIds, setPayerIds] = useState>(() => new Set(initialPayers.map(p => p.user_id))) - const [payerAmounts, setPayerAmounts] = useState>(() => { - const m: Record = {} - for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency) - return m - }) - const [pinnedPayers, setPinnedPayers] = useState>(() => new Set(initialPayers.map(p => p.user_id))) - - const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => { - if (hasTicketSplit(editing)) return 'ticket' - if (editing && editing.members && editing.members.length > 0) { - const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined) - return hasCustom ? 'custom' : 'equally' - } - return 'equally' - }) - - const [ticketItems, setTicketItems] = useState(() => editing ? readTicketItems(editing) : seed.ticketItems) - - const [customAmounts, setCustomAmounts] = useState>(() => { - const m: Record = {} - if (editing && editing.members) { - for (const member of editing.members) { - if (member.amount !== null && member.amount !== undefined) m[member.user_id] = amountToInputString(member.amount, currency) - } - } - return m - }) - - const [receipts, setReceipts] = useState(() => editing?.receipts || []) - const [pendingReceiptFiles, setPendingReceiptFiles] = useState(() => editing ? [] : seed.receiptFiles) - const [uploadingReceipt, setUploadingReceipt] = useState(false) - const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null) - - const handleReceiptFileSelect = (files: FileList | File[] | null) => { - if (!files || files.length === 0) return - setPendingReceiptFiles(prev => [...prev, ...Array.from(files)]) - } - - const handleRemoveReceipt = (receiptId: number) => { - setReceipts(prev => prev.filter(r => r.id !== receiptId)) - } - - const handleRemovePendingReceipt = (index: number) => { - setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index)) - } - - const [saving, setSaving] = useState(false) const [deleteArmed, setDeleteArmed] = useState(false) - const isTicketMode = splitMode === 'ticket' - const ticketInfo = useMemo(() => calculateTicketShares(ticketItems), [ticketItems]) - - const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) - const fx = preview(totalNum, currency) - const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) - const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) - const each = participants.size > 0 ? totalNum / participants.size : 0 - const equalShares = useMemo( - () => splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0), - [totalNum, participants, editing], - ) - - const placeholderShares = useMemo(() => { - const emptyParts = [...participants].filter(id => !customAmounts[id]) - if (emptyParts.length === 0) return {} - const enteredSum = [...participants] - .filter(id => customAmounts[id]) - .reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) - // Clamped toward zero on the total's own side so a negative total (#2176) - // still previews its negative equal shares — same as the desktop modal. - const rest = totalNum - enteredSum - const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest) - return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0) - }, [totalNum, participants, customAmounts, editing]) - - const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0) - const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum)) - // A negative total is a valid entry (a refund, #2176); only zero has nothing to say. - const valid = name.trim().length > 0 && payersOk && ( - isTicketMode - ? ticketValid - : totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced) - ) - - const onTotalChange = (v: string) => setTotal(v.replace(',', '.')) - - // Keep payer amounts summing to the total as it changes (also in ticket mode). - useEffect(() => { - if (!multiPayer) return - setPayerAmounts(prev => rebalancePayers(prev, pinnedPayers, payerIds, totalNum)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [totalNum]) - - const enableMultiPayer = () => { - const startPayers = payerIds.size > 0 ? new Set(payerIds) : new Set([payerId > 0 ? payerId : me]) - const pinned = new Set() - setPayerIds(startPayers) - setPinnedPayers(pinned) - setPayerAmounts(prev => rebalancePayers(prev, pinned, startPayers, totalNum)) - setMultiPayer(true) - } - - const disableMultiPayer = () => { - const [first] = [...payerIds] - setPayerId(first ?? me) - setMultiPayer(false) - } - - const togglePayer = (id: number) => { - const nextIds = new Set(payerIds) - const nextPinned = new Set(pinnedPayers) - if (nextIds.has(id)) { nextIds.delete(id); nextPinned.delete(id) } else { nextIds.add(id) } - setPayerIds(nextIds) - setPinnedPayers(nextPinned) - setPayerAmounts(prev => rebalancePayers(prev, nextPinned, nextIds, totalNum)) - } - - const onPayerAmountChange = (id: number, v: string) => { - const val = v.replace(',', '.') - const nextPinned = new Set(pinnedPayers) - nextPinned.add(id) - setPinnedPayers(nextPinned) - setPayerAmounts(prev => rebalancePayers({ ...prev, [id]: val }, nextPinned, payerIds, totalNum)) - } - - const handleCustomAmountChange = (id: number, val: string) => { - val = val.replace(',', '.') - if (val === '' || amountPattern(currency, true).test(val)) setCustomAmounts(prev => ({ ...prev, [id]: val })) - } - - const handleAddEmptyItem = () => { - setTicketItems(prev => [ - ...prev, - { id: String(Date.now() + Math.random()), name: '', price: '', participants: new Set(people.map(p => p.id)) }, - ]) - } - const handleUpdateItemName = (id: string, itemName: string) => setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name: itemName } : item)) - const handleUpdateItemPrice = (id: string, price: string) => { - price = price.replace(',', '.') - if (price === '' || amountPattern(currency, false).test(price)) setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item)) - } - const handleRemoveItem = (id: string) => setTicketItems(prev => prev.filter(item => item.id !== id)) - const handleToggleItemParticipant = (itemId: string, userId: number) => { - setTicketItems(prev => prev.map(item => { - if (item.id !== itemId) return item - const nextParts = new Set(item.participants) - if (nextParts.has(userId)) nextParts.delete(userId); else nextParts.add(userId) - return { ...item, participants: nextParts } - })) - } - - const toggleParticipant = (id: number) => { - const nextParts = new Set(participants) - if (nextParts.has(id)) { - nextParts.delete(id) - setCustomAmounts(prev => { const copy = { ...prev }; delete copy[id]; return copy }) - } else { - nextParts.add(id) - } - setParticipants(nextParts) - } - - const save = async () => { - if (!valid || saving) return - setSaving(true) - // A picked payer always goes out, even when nobody shares the expense: the - // server re-derives total_price from the payer sum (CostsPanel.helpers), so - // dropping the payer would store the entry with a total of 0. - const payerList = multiPayer - ? [...payerIds].map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 })).filter(p => p.amount !== 0) - : payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : [] - const memberList = [...participants].map(id => ({ - user_id: id, - amount: splitMode === 'custom' - ? (Number.parseFloat(customAmounts[id]) || 0) - : splitMode === 'ticket' - ? (ticketInfo.shares[id] || 0) - : null, - })) - const data = { - name: name.trim(), - category: cat, - currency, - payers: payerList, - members: memberList, - member_ids: [...participants], - expense_date: day || null, - total_price: totalNum, - note: note.trim() || null, - ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null, - ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), - ...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}), - } - try { - setUploadingReceipt(pendingReceiptFiles.length > 0) - await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => ( - editing - ? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] }) - : addBudgetItem(tripId, { ...data, receipt_file_ids: ids }) - )) - setPendingReceiptFiles([]) - onSaved() - } catch (err) { - const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds - toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError')) - setSaving(false) - } finally { - setUploadingReceipt(false) - } - } - const handleDelete = async () => { if (!editing) return if (!deleteArmed) { @@ -326,7 +99,6 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, } const initialOf = (p: TripMember) => (p.id === me ? t('costs.youShort') : (p.username || '?').charAt(0)).toUpperCase() - const nameOf = (p: TripMember) => (p.id === me ? t('costs.you') : p.username) const Avatar = ({ p, idx, size = 22, dim = false }: { p: TripMember; idx: number; size?: number; dim?: boolean }) => p.avatar_url