feat(currencies): ship sterling, and migrate it into existing datasets

GBP joins the currencies the app comes with: £, two decimals, and a
committed fallback rate of 8.704735 DKK to the pound. build.ts now
fetches it too, so a build with a network bakes a live GBP rate and one
without still covers it — the coverage check derives from the seed table,
so it demanded GBP as soon as the table listed it.

The seed table alone would not have delivered this. Baked currencies and
rates seed a NEW dataset and nothing else, so an existing dataset would
have been left typing the code, name, symbol and decimals by hand. A
4 -> 5 migration adds sterling to a dataset that lacks it, with its rate.

Supplying a rate is the one thing the "rates never backstop a cleared
rate" rule permits here: it forbids re-supplying a number the owner
deleted, not introducing one for a currency they have never had. A GBP
the owner already defined, or a GBP rate they already hold, is left
exactly as it is — asserted, including the case where their definition is
"wrong" (zero decimals), because it is theirs.

This exposed a real latent bug. The 2 -> 3 step seeded the live
seed-currency table BY REFERENCE, so adding sterling there would have
retroactively changed what an old dataset received from a step it had
already been through — silently, and only for whoever had not migrated
yet. Every step now carries its own frozen copy, and a source-level guard
fails the build if a live default is imported again. Scoping that guard
took two goes: matching the whole file flagged the comment explaining the
rule, and then flagged the frozen local copy that is the fix.

Two test fixtures used GBP as their example of an UNSUPPORTED currency
and now use CHF, which no seed table lists.

Verified in a browser by writing a schemaVersion 4 dataset into IndexedDB
and reloading: the currency table gains "British pound / £ / 2" with
"manual · 2026-09-02", the post currency selector offers GBP, and the
income and post survive untouched. Note for the next person — a migration
applies in memory on load and is written back on the next mutation, so
reading IndexedDB straight after a reload still shows the OLD version.
That is recorded in docs/TODO.md, because it made this verification look
like a failure at first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 22:27:28 +02:00
1 parent 89f2f4fd19
commit f4223573e4
14 files changed
+216 -25

No files matched your search

+7
View File
@@ -104,6 +104,13 @@ ship twice — re-introducing one is a regression, not a style choice.
worker's precache list (`src/swPrecache.test.ts`) — which must contain no
duplicate, because `cache.addAll` rejects on one, which is why the service
worker had never once installed in any environment.
- **A migration step must not import a live default** — no seed table, no baked
rates. Each step carries its own frozen copy of whatever it writes, because a
step's output must not change when today's defaults do: it would silently
rewrite what an old dataset receives, and only for whoever had not migrated
yet. → `src/store/migrations.test.ts`, scoped to import bindings (matching the
whole file flagged both the comment explaining the rule and the frozen local
copy that is the fix).
- **Every repository path named in a navigable document must be git-TRACKED** —
this file, `README.md`, and `docs/*.md`. → `src/docsLinks.test.ts`, added when
the docs moved into `docs/`: a restructure that quietly leaves thirty
+1 -1
View File
@@ -13,7 +13,7 @@ const outdir = path.join(process.cwd(), "dist");
const version = process.env.BUILD_VERSION ?? String(Date.now());
const FX_BASE: Currency = "DKK";
const FX_TARGETS: Currency[] = ["USD", "EUR"];
const FX_TARGETS: Currency[] = ["USD", "EUR", "GBP"];
const FX_TIMEOUT_MS = 8_000;
/**
+15 -1
View File
@@ -18,7 +18,7 @@ record.
no household mode. → [`PRODUCT.md`](PRODUCT.md)
- **Schema migrations are mandatory, not optional.** `schemaVersion` plus
ordered migration functions applied on load, because IndexedDB holds the
user's only copy of their financial data. Now at version 4.
user's only copy of their financial data. Now at version 5.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **A destructive write downloads a backup first.** Import and reset both export
before replacing, and abort if the export fails. → commit `ac3d18a`
@@ -136,6 +136,20 @@ record.
now" died with only a CORS error to go on. The build-time fetch never noticed,
because a server-side fetch follows redirects freely. → commit history, and
`src/store/fxApi.ts`
- **A migration must never read today's defaults.** The 2 -> 3 step seeded the
live seed-currency table by reference, so adding sterling to that table would
have retroactively changed what an old dataset got from a step it had already
been through — silently, and only for whoever had not migrated yet. Each step
now carries its own frozen copy, and a source-level guard fails the build if a
live default is imported again. → `src/store/migrations.ts`
- **Sterling ships with the app, and is migrated into datasets that already
exist.** Baked currencies and rates seed a NEW dataset and nothing else, so
the seed table alone would have left every existing user typing the code, name
symbol and decimals by hand. The 4 -> 5 step supplies the rate as well, which
is the one thing the "rates never backstop a cleared rate" rule permits here:
it forbids re-supplying a number the owner deleted, not introducing one for a
currency they have never had. A GBP the owner already defined is left exactly
as it is. → `src/store/migrations.ts`
- **The v3 -> v4 migration DROPS the stale URL rather than rewriting it** to the
new endpoint, so a dataset that stored it follows whatever the current default
is and the next move of the service needs no second migration for the same
+11 -4
View File
@@ -8,14 +8,21 @@ decisions behind it are in `docs/specs/` — one spec per sub-project,
each recording the alternatives that were rejected. Rules that outlived their
task moved to `AGENTS.md`.
**Current state:** merged to `main`. 457 tests passing,
**Current state:** merged to `main`. 464 tests passing,
`bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and
offline paths.
**Schema is at version 4.** v1 -> v2 made allocation rules a dated series;
**Schema is at version 5.** v1 -> v2 made allocation rules a dated series;
v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service
URL that can no longer work. All three are behaviour-preserving by construction
— not one changes a stored figure — and are tested as such.
URL that can no longer work; v4 -> v5 adds sterling to an existing dataset. All
four are behaviour-preserving by construction — not one changes a stored figure
— and are tested as such.
A migration applies **on load, in memory**; it is written back on the next
mutation, not by `load()` itself. So a freshly migrated dataset shows the new
shape immediately while IndexedDB still holds the old version for a while. That
is not a bug, but it will mislead you if you verify a migration by reading
IndexedDB instead of the app.
---
+1 -1
View File
@@ -1,7 +1,7 @@
import { SEED_CURRENCIES } from "./types.ts";
import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts";
export const SCHEMA_VERSION = 4;
export const SCHEMA_VERSION = 5;
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
+8 -2
View File
@@ -7,8 +7,14 @@ import { DEFAULT_CURRENCY_DIGITS, SEED_CURRENCIES } from "./types.ts";
* currencies, so the dataset is the authority and there is nothing global to
* assert. What remains true is what a NEW dataset starts with.
*/
test("a new dataset starts with the three currencies the app shipped with", () => {
expect(SEED_CURRENCIES.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
test("a new dataset starts with the currencies the app shipped with", () => {
expect(SEED_CURRENCIES.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]);
});
test("sterling carries the pound sign, so \"10£\" parses", () => {
const gbp = SEED_CURRENCIES.find((c) => c.code === "GBP")!;
expect(gbp.symbol).toBe("£");
expect(gbp.digits).toBe(2);
});
test("each seeded currency carries its decimals and a symbol to type", () => {
+1
View File
@@ -28,6 +28,7 @@ export const SEED_CURRENCIES: readonly CurrencyDef[] = [
{ code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" },
{ code: "USD", digits: 2, symbol: "$", name: "US dollar" },
{ code: "EUR", digits: 2, symbol: "\u20ac", name: "Euro" },
{ code: "GBP", digits: 2, symbol: "\u00a3", name: "British pound" },
];
/** Used when a currency has no definition. Two places is right almost always. */
+3 -2
View File
@@ -88,8 +88,9 @@ describe("validation", () => {
test("rejects an unsupported currency", () => {
const data = populated() as any;
data.settings.baseCurrency = "GBP";
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/GBP/);
// CHF, not GBP: GBP is one of the currencies the app ships with now.
data.settings.baseCurrency = "CHF";
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/CHF/);
});
test("rejects a malformed MonthId", () => {
+3 -2
View File
@@ -6,7 +6,7 @@ import { MissingRateError, toBase } from "../domain/fx.ts";
describe("the committed fallback rates", () => {
test("cover every non-base currency and are honestly labelled manual", () => {
expect(FALLBACK_FX_RATES.map((r) => r.currency).sort()).toEqual(["EUR", "USD"]);
expect(FALLBACK_FX_RATES.map((r) => r.currency).sort()).toEqual(["EUR", "GBP", "USD"]);
// "api" would claim a hardcoded number came from a rate service. Settings
// shows this field to the user.
expect(FALLBACK_FX_RATES.every((r) => r.source === "manual")).toBe(true);
@@ -18,6 +18,7 @@ describe("reading what the build baked in", () => {
const baked: FxRate[] = [
{ currency: "USD", baseUnitsPerOne: 6.812345, updatedAt: "2026-09-02", source: "api" },
{ currency: "EUR", baseUnitsPerOne: 7.456789, updatedAt: "2026-09-02", source: "api" },
{ currency: "GBP", baseUnitsPerOne: 8.712345, updatedAt: "2026-09-02", source: "api" },
];
test("an unset variable falls back to the committed constants", () => {
@@ -36,7 +37,7 @@ describe("reading what the build baked in", () => {
test.each([
["not JSON at all", "{oh dear"],
["a non-array", JSON.stringify({ USD: 6.8 })],
["an unsupported currency", JSON.stringify([{ currency: "GBP", baseUnitsPerOne: 8.6, updatedAt: "2026-09-02", source: "api" }])],
["an unsupported currency", JSON.stringify([{ currency: "CHF", baseUnitsPerOne: 8.6, updatedAt: "2026-09-02", source: "api" }])],
["a non-positive rate", JSON.stringify([{ currency: "USD", baseUnitsPerOne: 0, updatedAt: "2026-09-02", source: "api" }])],
["a missing rate value", JSON.stringify([{ currency: "USD", updatedAt: "2026-09-02", source: "api" }])],
["an empty array", JSON.stringify([])],
+10 -3
View File
@@ -20,9 +20,10 @@ import { SEED_CURRENCIES, type Currency, type FxRate } from "../domain/types.ts"
*
* Labelled "manual" rather than "api" on purpose: Settings shows this field,
* and a hardcoded constant must not claim it came from a rate service. The
* values are real (frankfurter.app, DKK base, 2026-09-01); EUR barely moves
* because DKK is pegged to it, so it ages well. USD does move — a stale USD
* number here is the reason the user can always edit or re-fetch a rate.
* values are real (frankfurter, DKK base, on the date each row carries); EUR
* barely moves because DKK is pegged to it, so it ages well. USD and GBP do
* move — a stale number here is the reason the user can always edit or
* re-fetch a rate.
*/
export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([
Object.freeze({
@@ -37,6 +38,12 @@ export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([
updatedAt: "2026-09-01",
source: "manual" as const,
}),
Object.freeze({
currency: "GBP" as Currency,
baseUnitsPerOne: 8.704735,
updatedAt: "2026-09-02",
source: "manual" as const,
}),
]);
/**
+1 -1
View File
@@ -63,7 +63,7 @@ describe("removing a currency", () => {
const data = draft();
actions.addCurrency(data, { code: "JPY", digits: 0 });
actions.removeCurrency(data, "JPY");
expect(data.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
expect(data.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]);
});
test("its exchange rate goes with it, so no orphan row is left behind", () => {
+99 -5
View File
@@ -167,10 +167,13 @@ describe("v2 -> v3: currencies become data", () => {
test("the three shipped currencies are added, with their decimals", () => {
const migrated = migrate(v2());
expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION);
// The first three come from this step, frozen at what it always produced.
// GBP is appended later, by 4 -> 5; see that block.
expect(migrated.currencies).toEqual([
{ code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" },
{ code: "USD", digits: 2, symbol: "$", name: "US dollar" },
{ code: "EUR", digits: 2, symbol: "€", name: "Euro" },
{ code: "GBP", digits: 2, symbol: "£", name: "British pound" },
]);
});
@@ -191,7 +194,9 @@ describe("v2 -> v3: currencies become data", () => {
const migrated = migrate(structuredClone(before));
expect(migrated.posts).toEqual(before.posts);
expect(migrated.months).toEqual(before.months);
expect(migrated.fxRates).toEqual(before.fxRates);
// Sterling's rate arrives later, in 4 -> 5; nothing this step does touches
// the rate table.
expect(migrated.fxRates.filter((r) => r.currency !== "GBP")).toEqual(before.fxRates);
expect(migrated.settings.baseCurrency).toBe("DKK");
expect(migrated.settings.foldStartMonth).toBe("2026-01");
});
@@ -218,7 +223,7 @@ describe("v2 -> v3: currencies become data", () => {
expect(migrated.posts[0]!.rules).toEqual([
{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } } },
]);
expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]);
});
/**
@@ -230,7 +235,9 @@ describe("v2 -> v3: currencies become data", () => {
const data = v2();
data.currencies = [{ code: "DKK", digits: 2 }, { code: "JPY", digits: 0 }];
const migrated = migrate(data);
expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "JPY"]);
// JPY survives, which is the point: the step appends nothing and discards
// nothing. GBP is appended afterwards by 4 -> 5.
expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "JPY", "GBP"]);
});
});
@@ -253,7 +260,7 @@ describe("v3 -> v4: the stale rate-service URL is dropped", () => {
test("a dataset holding the dead default loses the field entirely", () => {
const out = migrate(v3(STALE));
expect(out.settings.schemaVersion).toBe(4);
expect(out.settings.schemaVersion).toBe(SCHEMA_VERSION);
// Removed rather than rewritten to the new URL, so the dataset follows
// whatever the current default is and a future endpoint move needs no
// second migration for the same user.
@@ -268,6 +275,93 @@ describe("v3 -> v4: the stale rate-service URL is dropped", () => {
test("a dataset that never stored a URL is untouched apart from the version", () => {
const out = migrate(v3());
expect("fxApiUrl" in out.settings).toBe(false);
expect(out.settings.schemaVersion).toBe(4);
expect(out.settings.schemaVersion).toBe(SCHEMA_VERSION);
});
});
describe("v4 -> v5: GBP joins the prebaked currencies", () => {
const v4 = (currencies: any[], fxRates: any[] = []) => ({
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 4 },
currencies,
fxRates,
posts: [],
months: [],
purchases: [],
});
const DKK = { code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" };
const USD_RATE = {
currency: "USD",
baseUnitsPerOne: 6.449532,
updatedAt: "2026-09-01",
source: "manual" as const,
};
test("a dataset without GBP gains the definition and a rate", () => {
const out = migrate(v4([DKK], [USD_RATE]));
expect(out.settings.schemaVersion).toBe(SCHEMA_VERSION);
expect(out.currencies.map((c) => c.code)).toEqual(["DKK", "GBP"]);
const gbp = out.fxRates.find((r) => r.currency === "GBP")!;
expect(gbp.baseUnitsPerOne).toBe(8.704735);
// "manual", never "api": Settings shows this field, and a committed
// constant must not claim it came from a rate service.
expect(gbp.source).toBe("manual");
});
test("a GBP the owner defined themselves is left exactly as it is", () => {
// Zero decimals is wrong for sterling, which is the point: it is THEIR
// definition, and a migration that corrected it would be editing data.
const mine = { code: "GBP", digits: 0, symbol: "quid" };
const out = migrate(v4([DKK, mine]));
expect(out.currencies.find((c) => c.code === "GBP")).toEqual(mine);
});
test("a GBP rate the owner already has keeps their number", () => {
const mine = {
currency: "GBP",
baseUnitsPerOne: 9,
updatedAt: "2026-01-01",
source: "manual" as const,
};
const out = migrate(v4([DKK], [mine]));
expect(out.fxRates.filter((r) => r.currency === "GBP")).toEqual([mine]);
});
test("everything else is left untouched", () => {
const out = migrate(v4([DKK], [USD_RATE]));
expect(out.fxRates.find((r) => r.currency === "USD")).toEqual(USD_RATE);
expect(out.currencies[0]).toEqual(DKK);
});
test("GBP is not added twice when the migration is somehow reapplied", () => {
const once = migrate(v4([DKK]));
const codes = once.currencies.filter((c) => c.code === "GBP");
expect(codes).toHaveLength(1);
});
});
/**
* A migration's output must not change when today's defaults change. The 2 -> 3
* step originally seeded `SEED_CURRENCIES` by reference, so adding GBP to that
* table would have retroactively altered what an old dataset got from a step it
* had already been through — silently, and only for whoever had not migrated
* yet. Source-level, like eventCapture.test.ts, because the damage is invisible
* in review and in a green suite.
*/
test("no migration step imports a live default that a later commit could change", async () => {
const source = await Bun.file("src/store/migrations.ts").text();
// Scoped to IMPORT BINDINGS, not to the whole file. Matching anywhere is too
// blunt: it flagged the comment explaining this rule, and then flagged
// V3_SEED_CURRENCIES, the frozen local copy that is the fix. What must never
// happen is the module reading a constant another commit can change.
const imports = [...source.matchAll(/import\s+([\s\S]*?)\s+from\s+["'][^"']+["']/g)].map(
(m) => m[1]!,
);
// SCHEMA_VERSION is deliberately absent: migrate() reads it as the ceiling to
// run to, which must track the current version. It is not a default that a
// step writes into someone's data.
const live = ["SEED_CURRENCIES", "FALLBACK_FX_RATES", "BAKED_FX_RATES"];
const offenders = imports.filter((binding) =>
live.some((name) => new RegExp(`\\b${name}\\b`).test(binding)),
);
expect(offenders).toEqual([]);
});
+55 -2
View File
@@ -1,6 +1,31 @@
import { SCHEMA_VERSION } from "../domain/seed.ts";
import { SEED_CURRENCIES } from "../domain/types.ts";
import { STALE_FX_API_URL } from "./fxApi.ts";
/**
* What the 2 -> 3 step has always produced, frozen HERE rather than imported
* from the live seed table.
*
* This module must not read today's defaults. It originally seeded the live
* seed-currency table by reference, so adding a currency there would have
* retroactively changed what an old dataset received from a step it had already
* been through — silently, and only for whoever had not migrated yet.
* `src/store/migrations.test.ts` fails if a live default is referenced again;
* it matches source text, so do not name those constants even in a comment.
*/
const V3_SEED_CURRENCIES = [
{ code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" },
{ code: "USD", digits: 2, symbol: "$", name: "US dollar" },
{ code: "EUR", digits: 2, symbol: "\u20ac", name: "Euro" },
];
/** Sterling as 4 -> 5 adds it. Frozen for the same reason as the list above. */
const V5_GBP = { code: "GBP", digits: 2, symbol: "\u00a3", name: "British pound" };
const V5_GBP_RATE = {
currency: "GBP",
baseUnitsPerOne: 8.704735,
updatedAt: "2026-09-02",
source: "manual" as const,
};
import type { Dataset } from "../domain/types.ts";
export class UnsupportedSchemaError extends Error {
@@ -55,7 +80,7 @@ const MIGRATIONS: Array<(data: any) => any> = [
settings: { ...data.settings, schemaVersion: 3 },
currencies: Array.isArray(data.currencies)
? data.currencies
: SEED_CURRENCIES.map((currency) => ({ ...currency })),
: V3_SEED_CURRENCIES.map((currency) => ({ ...currency })),
}),
// 3 -> 4: drop a stored rate-service URL that can no longer work.
@@ -82,6 +107,34 @@ const MIGRATIONS: Array<(data: any) => any> = [
: { ...data.settings, schemaVersion: 4 },
};
},
// 4 -> 5: sterling joins the currencies the app ships with, and an existing
// dataset gets it too. Baked currencies and rates seed a NEW dataset and
// nothing else, so without this step the owner would have to type the code,
// name, symbol and decimals by hand to spend in a currency the app now
// claims to ship.
//
// The rate is supplied because there is no prior choice to overwrite: GBP has
// never existed in this dataset, so nothing is being silently replaced. That
// is the line the "rates never backstop a cleared rate" rule draws — it
// forbids re-supplying a number the owner removed, not introducing one for a
// currency they have never had. A GBP the owner already defined, or a GBP
// rate they already hold, is left exactly as it is.
//
// No stored figure changes: adding a currency definition and a rate for a
// currency nothing yet references cannot alter an amount already recorded.
(data: any) => {
const currencies = Array.isArray(data.currencies) ? data.currencies : [];
const fxRates = Array.isArray(data.fxRates) ? data.fxRates : [];
const hasGbp = currencies.some((c: any) => c?.code === "GBP");
const hasGbpRate = fxRates.some((r: any) => r?.currency === "GBP");
return {
...data,
settings: { ...data.settings, schemaVersion: 5 },
currencies: hasGbp ? currencies : [...currencies, { ...V5_GBP }],
fxRates: hasGbpRate || hasGbp ? fxRates : [...fxRates, { ...V5_GBP_RATE }],
};
},
];
export function migrate(raw: unknown): Dataset {
+1 -1
View File
@@ -208,7 +208,7 @@ describe("reset", () => {
"Food",
"Events and Social",
]);
expect(after.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
expect(after.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]);
expect(after.fxRates).toEqual(rates);
expect(after.purchases).toEqual([]);
expect(after.months).toHaveLength(1);