mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
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:
@@ -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' });
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user