diff --git a/build.ts b/build.ts index 2654572..8acaacf 100644 --- a/build.ts +++ b/build.ts @@ -3,11 +3,70 @@ import { rm } from "node:fs/promises"; import path from "node:path"; import { normalizeBase } from "./src/ui/basePath.ts"; import { buildManifest } from "./src/manifest.ts"; +import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./src/store/fxApi.ts"; +import { FALLBACK_FX_RATES } from "./src/store/bakedRates.ts"; +import type { Currency, FxRate } from "./src/domain/types.ts"; const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH); 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_TIMEOUT_MS = 8_000; + +/** + * Exchange rates embedded at build time so a fresh install can convert a EUR + * purchase on day one instead of erroring. They seed a new dataset only — see + * src/store/bakedRates.ts for why they must never backstop a missing rate. + * + * A network failure must NOT fail the build: it falls back to the committed + * constants, which are labelled "manual" so Settings never claims a hardcoded + * number came from a rate service. Fetched rates carry the build date rather + * than the date the browser happens to run, which is why parseFxResponse + * takes an explicit `updatedAt`. + * + * SKIP_FX_FETCH=1 forces the constants, for offline or reproducible builds. + * FX_API_URL overrides the endpoint. + */ +async function bakeFxRates(): Promise { + const buildDate = new Date().toISOString().slice(0, 10); + const describe = (rates: readonly FxRate[]) => + rates.map((r) => `${r.currency} ${r.baseUnitsPerOne}`).join(", "); + + if (process.env.SKIP_FX_FETCH === "1") { + console.log(`Baking the committed FX constants (SKIP_FX_FETCH=1): ${describe(FALLBACK_FX_RATES)}`); + return FALLBACK_FX_RATES; + } + + const template = process.env.FX_API_URL ?? DEFAULT_FX_API_URL; + try { + const response = await fetch(buildFxUrl(template, FX_BASE, FX_TARGETS), { + signal: AbortSignal.timeout(FX_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`rate service returned ${response.status}`); + + const rates = parseFxResponse(await response.json(), FX_BASE, buildDate); + // A partial response would leave one currency unconvertible on a fresh + // install, which is the whole problem this solves. Prefer the complete + // constants over an incomplete live answer. + const covered = new Set(rates.map((r) => r.currency)); + const missing = FX_TARGETS.filter((c) => !covered.has(c)); + if (missing.length > 0) throw new Error(`response omitted ${missing.join(", ")}`); + + console.log(`Baked live FX rates for ${buildDate}: ${describe(rates)}`); + return rates; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + console.warn( + `Could not fetch FX rates (${message}); baking the committed constants instead: ${describe(FALLBACK_FX_RATES)}`, + ); + return FALLBACK_FX_RATES; + } +} + +const bakedFxRates = await bakeFxRates(); + await rm(outdir, { recursive: true, force: true }); const result = await Bun.build({ @@ -44,6 +103,9 @@ const result = await Bun.build({ define: { "process.env.NODE_ENV": JSON.stringify("production"), "process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath), + // Double-encoded on purpose: the inlined literal must be a JSON *string* + // for parseBakedRates to parse at runtime. + "process.env.BUN_PUBLIC_BAKED_FX_RATES": JSON.stringify(JSON.stringify(bakedFxRates)), }, }); diff --git a/src/domain/seed.ts b/src/domain/seed.ts index 5f32daa..4eadc1d 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -1,4 +1,4 @@ -import type { Dataset, MonthId, Post } from "./types.ts"; +import type { Dataset, FxRate, MonthId, Post } from "./types.ts"; export const SCHEMA_VERSION = 2; @@ -9,10 +9,15 @@ export function newId(): string { } /** - * The first-run dataset. Takes the start month as an argument because the - * domain layer must not read the ambient clock. + * The first-run dataset. Takes the start month AND the seed exchange rates as + * arguments because the domain layer must read neither the ambient clock nor + * the ambient environment. The store passes `BAKED_FX_RATES`; callers that + * want a bare dataset pass nothing. */ -export function createSeedDataset(startMonth: MonthId): Dataset { +export function createSeedDataset( + startMonth: MonthId, + fxRates: readonly FxRate[] = [], +): Dataset { const posts: Post[] = SEED_POST_NAMES.map((name, order) => ({ id: newId(), name, @@ -30,7 +35,9 @@ export function createSeedDataset(startMonth: MonthId): Dataset { foldStartMonth: startMonth, schemaVersion: SCHEMA_VERSION, }, - fxRates: [], + // Copied, so a caller passing shared constants cannot have them edited + // out from under it the first time the user changes a rate. + fxRates: fxRates.map((rate) => ({ ...rate })), posts, months: [ { id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} }, diff --git a/src/store/bakedRates.test.ts b/src/store/bakedRates.test.ts new file mode 100644 index 0000000..c00d295 --- /dev/null +++ b/src/store/bakedRates.test.ts @@ -0,0 +1,76 @@ +import { test, expect, describe } from "bun:test"; +import { FALLBACK_FX_RATES, parseBakedRates } from "./bakedRates.ts"; +import { createSeedDataset } from "../domain/seed.ts"; +import type { FxRate } from "../domain/types.ts"; +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"]); + // "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); + expect(FALLBACK_FX_RATES.every((r) => r.baseUnitsPerOne > 0)).toBe(true); + }); +}); + +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" }, + ]; + + test("an unset variable falls back to the committed constants", () => { + expect(parseBakedRates(undefined)).toEqual(FALLBACK_FX_RATES); + }); + + test("a well-formed payload is used as-is", () => { + expect(parseBakedRates(JSON.stringify(baked))).toEqual(baked); + }); + + test("six decimal places survive — a rate is not money", () => { + const [usd] = parseBakedRates(JSON.stringify(baked)); + expect(usd!.baseUnitsPerOne).toBe(6.812345); + }); + + 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" }])], + ["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([])], + ])("%s falls back rather than booting with junk", (_label, payload) => { + expect(parseBakedRates(payload)).toEqual(FALLBACK_FX_RATES); + }); +}); + +describe("what baked rates are allowed to do", () => { + test("they seed a new dataset", () => { + const data = createSeedDataset("2026-09", FALLBACK_FX_RATES); + expect(data.fxRates).toEqual([...FALLBACK_FX_RATES]); + }); + + test("seeding without them still yields no rates at all", () => { + expect(createSeedDataset("2026-09").fxRates).toEqual([]); + }); + + test("the seeded rows are copies, so editing one cannot rewrite the constants", () => { + const data = createSeedDataset("2026-09", FALLBACK_FX_RATES); + data.fxRates[0]!.baseUnitsPerOne = 999; + expect(FALLBACK_FX_RATES[0]!.baseUnitsPerOne).not.toBe(999); + }); + + /** + * The decision this guards: clearing a rate REMOVES the row so + * MissingRateError surfaces, instead of silently converting money at a + * number the user never chose. Baked rates seed; they never backstop. + */ + test("they do NOT backstop a rate the user has cleared", () => { + const data = createSeedDataset("2026-09", FALLBACK_FX_RATES); + data.fxRates = data.fxRates.filter((r) => r.currency !== "EUR"); + expect(() => + toBase({ amount: 100, currency: "EUR" }, data.settings.baseCurrency, data.fxRates), + ).toThrow(MissingRateError); + }); +}); diff --git a/src/store/bakedRates.ts b/src/store/bakedRates.ts new file mode 100644 index 0000000..c114e25 --- /dev/null +++ b/src/store/bakedRates.ts @@ -0,0 +1,99 @@ +import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts"; + +/** + * Exchange rates baked in at build time so a fresh install can convert a EUR + * purchase on day one instead of erroring. + * + * They seed a NEW dataset and nothing else. They deliberately do NOT act as a + * fallback whenever a rate is missing: clearing a rate removes its row so + * `MissingRateError` surfaces, rather than silently converting money at a + * number the user never chose. `src/store/bakedRates.test.ts` pins that. + * + * This lives in the store layer, not the domain, because reading the ambient + * environment is exactly what `src/domain/` may not do. `createSeedDataset` + * takes the rates as an argument instead. + */ + +/** + * The last-resort rates, committed so a build with no network still produces a + * working app. DKK per one unit, six decimals, never rounded to money. + * + * 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. + */ +export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([ + Object.freeze({ + currency: "USD" as Currency, + baseUnitsPerOne: 6.449532, + updatedAt: "2026-09-01", + source: "manual" as const, + }), + Object.freeze({ + currency: "EUR" as Currency, + baseUnitsPerOne: 7.474959, + updatedAt: "2026-09-01", + source: "manual" as const, + }), +]); + +/** The currencies a baked payload must cover: everything but the base. */ +const REQUIRED = CURRENCIES.filter((c) => c !== "DKK"); + +function isUsableRate(value: unknown): value is FxRate { + if (typeof value !== "object" || value === null) return false; + const rate = value as Record; + return ( + CURRENCIES.includes(rate.currency as Currency) && + typeof rate.baseUnitsPerOne === "number" && + Number.isFinite(rate.baseUnitsPerOne) && + rate.baseUnitsPerOne > 0 && + typeof rate.updatedAt === "string" && + (rate.source === "api" || rate.source === "manual") + ); +} + +/** + * Parses what `build.ts` embedded, falling back to the committed constants on + * anything unusable — an unset variable (a plain `bun --hot`), a build whose + * fetch failed, or a malformed payload. Falling back beats throwing: this runs + * during first-run seeding, and an app that will not boot is worse than an app + * with a slightly stale USD rate the user can edit. + */ +export function parseBakedRates(raw: string | undefined): readonly FxRate[] { + if (!raw) return FALLBACK_FX_RATES; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return FALLBACK_FX_RATES; + } + + if (!Array.isArray(parsed) || !parsed.every(isUsableRate)) return FALLBACK_FX_RATES; + + // A partial payload would leave one currency unconvertible on a fresh + // install, which is the whole problem this feature exists to solve. + const covered = new Set(parsed.map((rate) => rate.currency)); + if (!REQUIRED.every((currency) => covered.has(currency))) return FALLBACK_FX_RATES; + + return parsed; +} + +/** + * try/catch, not a `typeof process` guard. Bun inlines the literal only when + * the variable is set; unset, the bare reference reaches the browser and + * throws before the app boots. A `typeof` test survives inlining and discards + * the inlined value. See `readBasePathEnv` in src/ui/basePath.ts. + */ +export function readBakedRatesEnv(): string | undefined { + try { + return process.env.BUN_PUBLIC_BAKED_FX_RATES; + } catch { + return undefined; + } +} + +export const BAKED_FX_RATES = parseBakedRates(readBakedRatesEnv()); diff --git a/src/store/fxApi.test.ts b/src/store/fxApi.test.ts index 5cf17a4..0b01ce9 100644 --- a/src/store/fxApi.test.ts +++ b/src/store/fxApi.test.ts @@ -38,3 +38,11 @@ describe("parseFxResponse", () => { expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK")).toThrow(/EUR/); }); }); + +test("an explicit updatedAt is used instead of the clock", () => { + // The build bakes rates in with the BUILD date, not the date the browser + // happens to run parseFxResponse. + const rates = parseFxResponse({ rates: { EUR: 0.13378, USD: 0.15505 } }, "DKK", "2026-09-01"); + expect(rates.map((r) => r.updatedAt)).toEqual(["2026-09-01", "2026-09-01"]); + expect(rates.every((r) => r.source === "api")).toBe(true); +}); diff --git a/src/store/fxApi.ts b/src/store/fxApi.ts index 3eda3e3..0b6abc9 100644 --- a/src/store/fxApi.ts +++ b/src/store/fxApi.ts @@ -13,7 +13,12 @@ export function buildFxUrl( .replace("{targets}", targets.join(",")); } -export function parseFxResponse(body: unknown, base: Currency): FxRate[] { +export function parseFxResponse( + body: unknown, + base: Currency, + /** Defaults to now. The build passes its own date when baking rates in. */ + updatedAt: string = new Date().toISOString(), +): FxRate[] { const rates = typeof body === "object" && body !== null ? (body as Record).rates @@ -22,7 +27,6 @@ export function parseFxResponse(body: unknown, base: Currency): FxRate[] { throw new Error("Exchange rate response has no `rates` object"); } - const updatedAt = new Date().toISOString(); const out: FxRate[] = []; for (const [currency, value] of Object.entries(rates as Record)) { diff --git a/src/store/index.ts b/src/store/index.ts index 452b2ae..dd98131 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,6 +1,7 @@ import { monthOf } from "../domain/months.ts"; import { readDataset, writeDataset } from "./db.ts"; import { createSnapshotStore } from "./snapshot.ts"; +import { BAKED_FX_RATES } from "./bakedRates.ts"; /** The current month, read at module load. The UI owns the clock; the domain does not. */ export const currentMonth = monthOf(new Date().toISOString().slice(0, 10)); @@ -8,6 +9,9 @@ export const currentMonth = monthOf(new Date().toISOString().slice(0, 10)); export const store = createSnapshotStore( { read: readDataset, write: writeDataset }, currentMonth, + // First run only, so a fresh install can convert a EUR purchase on day one. + // Never a backstop for a rate the user has cleared. + BAKED_FX_RATES, ); export * from "./snapshot.ts"; diff --git a/src/store/snapshot.test.ts b/src/store/snapshot.test.ts index e91d324..c9869dc 100644 --- a/src/store/snapshot.test.ts +++ b/src/store/snapshot.test.ts @@ -180,3 +180,28 @@ test("get before load throws rather than returning empty data", () => { const store = createSnapshotStore(fake.persistence, "2026-09"); expect(() => store.get()).toThrow(/not loaded/i); }); + +describe("seed rates", () => { + test("a first run seeds the rates it was given", async () => { + const fake = fakePersistence(null); + const rates = [ + { currency: "EUR" as const, baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" as const }, + ]; + const store = createSnapshotStore(fake.persistence, "2026-09", rates); + await store.load(); + expect(store.get().fxRates).toEqual(rates); + }); + + test("an existing dataset is never given seed rates", async () => { + // Loading stored data must not touch fxRates — the user may have + // deliberately cleared one, and re-adding it would convert money at a + // number they never chose. + const stored = createSeedDataset("2026-01"); + const fake = fakePersistence(stored); + const store = createSnapshotStore(fake.persistence, "2026-09", [ + { currency: "EUR", baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" }, + ]); + await store.load(); + expect(store.get().fxRates).toEqual([]); + }); +}); diff --git a/src/store/snapshot.ts b/src/store/snapshot.ts index 43fd5e1..94f6e84 100644 --- a/src/store/snapshot.ts +++ b/src/store/snapshot.ts @@ -1,5 +1,5 @@ import { createSeedDataset } from "../domain/seed.ts"; -import type { Dataset, MonthId } from "../domain/types.ts"; +import type { Dataset, FxRate, MonthId } from "../domain/types.ts"; export interface Persistence { read(): Promise; @@ -19,6 +19,13 @@ export interface SnapshotStore { export function createSnapshotStore( persistence: Persistence, currentMonth: MonthId, + /** + * Rates for a FIRST RUN only. Passed in rather than read here so this module + * stays free of the ambient environment; src/store/index.ts supplies + * BAKED_FX_RATES. Loading an existing dataset never touches its rates — the + * user may have cleared one deliberately. + */ + seedRates: readonly FxRate[] = [], ): SnapshotStore { let snapshot: Dataset | null = null; const listeners = new Set(); @@ -64,7 +71,7 @@ export function createSnapshotStore( notify(); return; } - await commit(createSeedDataset(currentMonth)); + await commit(createSeedDataset(currentMonth, seedRates)); }, get,