From aa848cc6b116afcec7f42dbb919bc69901ca66e0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 13:15:37 +0200 Subject: [PATCH] fix(budget): keep a payment's currency when it is edited on a phone The mobile payment sheet always sent the display currency, while it seeded the amount raw in the currency the payment was recorded in. Correcting only the day of a 1000 JPY payment turned it into 1000 EUR and froze a fresh FX rate for it. The sheet now carries its own currency, seeded from the payment (or the display currency for a new one) with the same picker the expense sheet has, and the payment row shows the recorded amount next to the converted one when they differ, like the desktop row does. The day is now required in both payment forms. Cleared, the server stored NULL and the ledger quietly fell back to the day the payment was recorded. --- .../src/components/Budget/CostsPanel.test.tsx | 15 +++++++ client/src/components/Budget/CostsPanel.tsx | 2 +- .../mobile/screens/trip/tabs/MCostsTab.tsx | 45 +++++++++++++++---- .../tests/unit/mobile/trip/MCostsTab.test.tsx | 40 +++++++++++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) 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() + }) })