mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user