fix(trips): pin place prices when the trip currency changes

A place price with no currency of its own means "the trip's own currency" —
that is how the PDF export and the place chips read it since #1519. But
rebaseTripCurrency() only pinned budget_items and budget_settlements, so
switching a trip's currency silently redenominated every implicit place
price: a €15 museum on a trip moved to JPY started reading as ¥15. Same
class of mismatch as #1543, on the places surface.

Pin priced, currency-less places to the outgoing currency inside the same
transaction. No amount is rewritten — the figure the user typed keeps its
real-world value, it just stops being ambiguous about which unit it is in.
Bump updated_at as well: it doubles as the optimistic-concurrency token
(#1135), so a client holding the pre-switch row can no longer write the
pin away.

Document place prices in the wiki's currency model, and drop the now-stale
"in EUR" from the PDF export's estimated-cost stat.
This commit is contained in:
jubnl
2026-07-14 20:56:36 +02:00
parent 8267650b2d
commit ecef9e06a5
4 changed files with 49 additions and 3 deletions
+17 -2
View File
@@ -142,7 +142,7 @@ export async function freezeForeignRate(
}
/**
* Re-anchor a trip's budget when its base currency changes (#1543).
* Re-anchor a trip's money when its base currency changes (#1543).
*
* Every frozen `exchange_rate` is "units of the row's currency per 1 *trip*
* currency", and `currency = NULL` means "the trip's own currency" — both are
@@ -156,6 +156,11 @@ export async function freezeForeignRate(
* No stored amount is rewritten — each expense keeps the figure the user typed,
* in the currency they typed it in, and its real-world value is preserved.
*
* Place prices follow the same NULL = "the trip's own currency" convention (that
* is how the PDF export and the place chips read them), so they are pinned too —
* otherwise a €15 museum on a trip switched to JPY starts reading as ¥15. They
* carry no frozen rate, so pinning the currency is all they need.
*
* Must run *before* the (synchronous) trip update, while the old currency is
* still in `trips`, and is a no-op when the currency isn't actually changing.
*/
@@ -193,7 +198,17 @@ export async function rebaseTripCurrency(
}
};
db.transaction(() => { rebase('budget_items'); rebase('budget_settlements'); })();
// Only priced places have anything to denominate; a currency on a free place would
// just be noise. `updated_at` doubles as the optimistic-concurrency token (#1135),
// so bumping it stops a client holding the pre-switch row from writing the pin away.
const pinPlaces = () => {
db.prepare(`
UPDATE places SET currency = ?, updated_at = CURRENT_TIMESTAMP
WHERE trip_id = ? AND price IS NOT NULL AND (currency IS NULL OR currency = '')
`).run(prev, tripId);
};
db.transaction(() => { rebase('budget_items'); rebase('budget_settlements'); pinPlaces(); })();
}
export function createBudgetItem(
@@ -224,6 +224,36 @@ describe('rebaseTripCurrency', () => {
}
});
it('BUDGET-SVC-DB-009: pins currency-less place prices to the outgoing currency', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
const priced = (price: number | null, currency: string | null) => {
const r = testDb.prepare('INSERT INTO places (trip_id, name, price, currency) VALUES (?, ?, ?, ?)')
.run(trip.id, 'Place', price, currency);
return Number(r.lastInsertRowid);
};
// A place that inherits the trip's base (currency NULL), one priced in its own
// currency, and one with no price at all.
const implicit = priced(15, null);
const jpy = priced(1500, 'JPY');
const free = priced(null, null);
await rebaseTripCurrency(trip.id, 'JPY');
const placeRow = (id: number) =>
testDb.prepare('SELECT price, currency FROM places WHERE id = ?')
.get(id) as { price: number | null; currency: string | null };
// The implicit place really held euros, so it is stamped EUR rather than silently
// becoming ¥15 — the amount the user typed is never rewritten.
expect(placeRow(implicit)).toEqual({ price: 15, currency: 'EUR' });
expect(placeRow(jpy)).toEqual({ price: 1500, currency: 'JPY' });
// Nothing to denominate without a price: leave it inheriting the trip's currency.
expect(placeRow(free)).toEqual({ price: null, currency: null });
});
it('BUDGET-SVC-DB-008: is a no-op when the currency is unchanged', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip' });
+1
View File
@@ -28,6 +28,7 @@ Changing it is **not** a relabelling — it is a re-basing, and TREK does the wo
- Expenses that had no currency of their own (they simply inherited the trip's) are **pinned to the old currency** first. A 9 000 ₽ expense on a trip switching from RUB to EUR stays *9 000 ₽*; it does not silently become 9 000 €.
- Every frozen exchange rate is **re-anchored** to the new base, because a frozen rate is stored relative to the trip currency (see below).
- **Place prices** are pinned the same way. A place price also inherits the trip's currency unless you gave it one of its own, so a €15 museum on a trip switching to JPY is stamped EUR rather than starting to read as ¥15.
The numbers you typed are never rewritten. Each expense keeps its original amount, in its original currency, and its real-world value survives the switch — only the base the balances are expressed in changes.
+1 -1
View File
@@ -23,7 +23,7 @@ Open the Day Plan sidebar in the trip planner. Click the **PDF** button in the t
- **Days** — total number of days in the trip
- **Places** — total places in your trip's place list
- **Planned** — number of unique places assigned to at least one day
- **Estimated cost** — sum of all assigned place prices in EUR (hidden if zero)
- **Estimated cost** — sum of all assigned place prices, shown in the trip's currency (hidden if zero)
### Per-day pages