diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 0a71e0e30..71613bcc7 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -1308,6 +1308,21 @@ describe('CostsPanel — payment modal', () => { await waitFor(() => expect(put).toBeTruthy()) expect(put!.settled_at).toBe('2025-06-10') }) + + it('FE-W5COSTS-077: a payment cannot be saved without a day', async () => { + // Cleared, the server would store NULL and the ledger would quietly move + // the payment back to the day it was recorded on. + const user = userEvent.setup() + mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] }) + + await user.click(await screen.findByTitle('Edit')) + const save = screen.getByRole('button', { name: 'Save' }) + expect(save).toBeEnabled() + + await user.click(document.querySelector('button[aria-haspopup="dialog"]') as HTMLElement) + await user.click(screen.getByRole('button', { name: 'Clear date' })) + expect(save).toBeDisabled() + }) }) describe('CostsPanel — expense modal', () => { diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index e5513202c..bf11bc6aa 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1022,7 +1022,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav const [saving, setSaving] = useState(false) const amt = Number.parseFloat(amount) || 0 - const valid = amt > 0 && fromId !== toId + const valid = amt > 0 && fromId !== toId && !!day const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username })) const save = async () => { diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 7e18803f6..4c3db1035 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -15,7 +15,9 @@ import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' import { readUserNote, settlementDate } 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' @@ -724,6 +726,11 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
{t('costs.payment')}
{personName(settlement.from_user_id)} → {personName(settlement.to_user_id)}
+ {cur !== base && ( +
+ {formatMoney(settlement.amount, cur, locale)} {'→'} {formatMoney(amount, base, locale)} +
+ )}
{formatMoney(amount, base, locale)} @@ -775,9 +782,11 @@ function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: { * 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 - * settle-up card itself needs: from, to, amount in the display currency, and - * the day it happened (editable like an expense's, unlike the legacy - * created_at-only date — see `settlementDate` in CostsPanel.helpers.ts). + * settle-up card itself needs: from, to, amount plus its currency (a new + * payment starts in the display currency, a reopened one keeps the currency + * it was recorded in, like the desktop modal), and the day it happened + * (editable like an expense's, unlike the legacy created_at-only date, see + * `settlementDate` in CostsPanel.helpers.ts). */ function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toast, t, onSaved }: { open: boolean @@ -794,25 +803,28 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toa 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 [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, (editing.currency || base).toUpperCase()) : '') + setAmount(editing ? amountToInputString(editing.amount, cur) : '') + setCurrency(cur) setDay(editing ? settlementDate(editing) : localToday()) setSaving(false) }, [open, editing, me, base, people]) const amt = Number.parseFloat(amount.replace(',', '.')) || 0 - const valid = amt > 0 && fromId !== toId + const valid = amt > 0 && fromId !== toId && !!day const save = async () => { if (!valid || saving) return setSaving(true) - const data = { from_user_id: fromId, to_user_id: toId, amount: amt, currency: base, settled_at: day } + const data = { from_user_id: fromId, to_user_id: toId, amount: amt, currency, settled_at: day } try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) @@ -853,11 +865,26 @@ function AddPaymentSheet({ open, editing, onClose, tripId, base, people, me, toa {t('costs.amount')}
setAmount(e.target.value)} placeholder="0.00" className={FIELD_CLS} /> - {base} + {currency}
- {t('costs.day')} - +
+
+ {t('costs.currency')} + setCurrency(String(v))} + searchable + size="sm" + options={currenciesWith(currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} + style={{ width: '100%' }} + /> +
+
+ {t('costs.day')} + +
+
diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index bf90a7d57..5a602c501 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -626,4 +626,44 @@ describe('MCostsTab', () => { expect(del).toHaveBeenCalledWith(7, 502) await waitFor(() => expect(settlementBases).toHaveLength(2)) }) + + it('FE-MOB-COSTT-043: editing a payment keeps the currency it was recorded in, not the display currency', async () => { + // Recorded in pounds while the phone shows dollars: the row says so, the + // sheet reopens in GBP, and a date-only correction must not re-declare the + // amount as dollars (the server would freeze a fresh FX rate for it). + const payment = { id: 505, from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28', created_at: '2026-04-30T09:00:00Z' } + serveSettlement({ ...SETTLEMENT, settlements: [payment] }) + const update = vi.spyOn(budgetApi, 'updateSettlement').mockResolvedValue({}) + await renderTab() + + const row = rowOf('costs.payment') + expect(within(row).getByText('£10.00 → $20.00')).toBeInTheDocument() + expect(within(row).getByText('$20.00')).toBeInTheDocument() + + fireEvent.click(within(row).getByRole('button', { name: 'common.edit' })) + const dialog = screen.getByRole('dialog', { name: 'costs.editPayment' }) + expect(within(dialog).getByDisplayValue('10.00')).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: /GBP/ })).toBeInTheDocument() + + fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' })) + expect(update).toHaveBeenCalledWith(7, 505, { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28' }) + }) + + it('FE-MOB-COSTT-044: a payment cannot be saved without a day', async () => { + // Cleared, the server would store NULL and the ledger would quietly fall + // back to the day the payment was recorded on. + const create = vi.spyOn(budgetApi, 'createSettlement').mockResolvedValue({}) + await renderTab() + fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' })) + const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' }) + const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' }) + fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '12,5' } }) + expect(submit).toBeEnabled() + + fireEvent.click(dialog.querySelector('button[aria-haspopup="dialog"]') as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: 'Clear date' })) + expect(submit).toBeDisabled() + fireEvent.click(submit) + expect(create).not.toHaveBeenCalled() + }) })