Files
TREK/client/src/components/Planner/BookingCostsSection.tsx
T
Maurice 06bee95ae3 fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked cost is labelled in the trip currency, a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
2026-09-26 04:22:50 +00:00

99 lines
6.2 KiB
TypeScript

import { Plus, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatMoney } from '../../utils/formatters'
import { catMeta } from '../Budget/costsCategories'
import type { BudgetItem } from '../../types'
/**
* The Costs block inside a booking modal — and, since #1298, inside the place
* form, which links its expense the same way. Replaces the old inline price +
* budget category fields: when no expense is linked yet it offers a "create
* expense" button (the modal saves its own record first, then opens the full
* Costs editor); once linked it shows the expense with edit / remove actions.
*
* Exactly one of reservationId / placeId is set — they are the two sides of the
* same link, and the block behaves identically on both.
*/
export function BookingCostsSection({ reservationId, placeId = null, hintKey = 'reservations.createExpenseHint', pendingExpense, onCreate, onEdit, onRemove }: {
reservationId: number | null
/** Set instead of reservationId when the block sits in the place form (#1298). */
placeId?: number | null
/** What gets saved before the editor opens — "the booking" or "the place". */
hintKey?: string
/** A cost parsed from an import that will be linked on save — previewed before the booking exists. */
pendingExpense?: { total_price: number; currency?: string | null; category: string } | null
onCreate: () => void
onEdit: (item: BudgetItem) => void
onRemove: (item: BudgetItem) => void
}) {
const { t, locale } = useTranslation()
const budgetItems = useTripStore(s => s.budgetItems)
const trip = useTripStore(s => s.trip)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
// An amount is printed in its own currency, unconverted. One saved without a
// currency is in the trip's own (#2525), which is how Costs reads it; labelling
// it with the display currency turned a 120 EUR deposit into $120.00.
const ownCurrency = (currency: string | null | undefined) => currency || trip?.currency || base
const linked = reservationId
? budgetItems.find(i => i.reservation_id === reservationId)
: placeId
? budgetItems.find(i => i.place_id === placeId)
: null
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
// Import review (booking not saved yet): preview the parsed cost that will be linked on save.
if (!linked && pendingExpense && pendingExpense.total_price > 0) {
const meta = catMeta(pendingExpense.category)
const Icon = meta.Icon
return (
<div>
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
</div>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}</span>
</div>
</div>
)
}
if (linked) {
const meta = catMeta(linked.category)
const Icon = meta.Icon
return (
<div>
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
</div>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, ownCurrency(linked.currency), locale)}</span>
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
</div>
</div>
)
}
return (
<div>
<label className={labelCls}>{t('reservations.costsLabel')}</label>
<button type="button" onClick={onCreate}
className="bg-surface-secondary border border-edge text-content"
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={15} /> {t('reservations.createExpense')}
</button>
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 6 }}>{t(hintKey)}</div>
</div>
)
}