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.
This commit is contained in:
Maurice
2026-09-12 13:15:37 +02:00
parent 3dd6674d3f
commit aa848cc6b1
4 changed files with 92 additions and 10 deletions
@@ -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', () => {
+1 -1
View File
@@ -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 () => {
@@ -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
<div className="min-w-0 flex-1">
<div className="truncate text-[0.8125rem] font-bold text-m-ink">{t('costs.payment')}</div>
<div className="truncate font-geist text-[0.65625rem] text-m-faint">{personName(settlement.from_user_id)} → {personName(settlement.to_user_id)}</div>
{cur !== base && (
<div className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
{formatMoney(settlement.amount, cur, locale)} {'→'} {formatMoney(amount, base, locale)}
</div>
)}
</div>
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-[11px] py-1 font-geist text-[0.75rem] font-extrabold tabular-nums text-m-ink">
{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
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.amount')}</Eyebrow>
<div className="flex items-center gap-2">
<input type="text" inputMode="decimal" value={amount} onChange={e => setAmount(e.target.value)} placeholder="0.00" className={FIELD_CLS} />
<span className="flex-none font-geist text-[0.75rem] font-bold text-m-faint">{base}</span>
<span className="flex-none font-geist text-[0.75rem] font-bold text-m-faint">{currency}</span>
</div>
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.day')}</Eyebrow>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
<div className="mt-[14px] flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[7px] uppercase">{t('costs.currency')}</Eyebrow>
<CustomSelect
value={currency}
onChange={v => setCurrency(String(v))}
searchable
size="sm"
options={currenciesWith(currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
style={{ width: '100%' }}
/>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[7px] uppercase">{t('costs.day')}</Eyebrow>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
</div>
</div>
</div>
<FormSheetFooter onCancel={onClose} cancelLabel={t('common.cancel')} onSubmit={save} submitLabel={editing ? t('common.save') : t('costs.addPayment')} submitDisabled={!valid || saving} />
</MSheet>
@@ -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()
})
})