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()
+ })
})