From ecef9e06a5893a6c82c9ee382edf854ec0305d9b Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 14 Jul 2026 20:56:36 +0200 Subject: [PATCH] fix(trips): pin place prices when the trip currency changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/src/services/budgetService.ts | 19 ++++++++++-- .../unit/services/budgetServiceDb.test.ts | 30 +++++++++++++++++++ wiki/Currencies.md | 1 + wiki/PDF-Export.md | 2 +- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts index 8d58bd29f..6715add00 100644 --- a/server/src/services/budgetService.ts +++ b/server/src/services/budgetService.ts @@ -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( diff --git a/server/tests/unit/services/budgetServiceDb.test.ts b/server/tests/unit/services/budgetServiceDb.test.ts index 7854315b8..c68ef67c2 100644 --- a/server/tests/unit/services/budgetServiceDb.test.ts +++ b/server/tests/unit/services/budgetServiceDb.test.ts @@ -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' }); diff --git a/wiki/Currencies.md b/wiki/Currencies.md index 6e9ced9f9..4dc676109 100644 --- a/wiki/Currencies.md +++ b/wiki/Currencies.md @@ -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. diff --git a/wiki/PDF-Export.md b/wiki/PDF-Export.md index 36f7d4d03..cb16c9d05 100644 --- a/wiki/PDF-Export.md +++ b/wiki/PDF-Export.md @@ -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