feat: user-defined currencies, and amounts that carry their own currency

Two requests. The second is small; the first changed the money layer.

TYPING THE CURRENCY. The purchase total field now reads "30$" as thirty
dollars, "€25,50" as EUR 25.50, "250 kr" as DKK, "1.234,56 usd" as USD
1234.56, and sets the currency selector from what was typed. Entry speed is a
stated product feature and a second control per amount works against it.

The field is type="text", not type="number": a number input silently discards
anything non-numeric, so "30$" would never reach the parser. It keeps its own
text so a half-typed "30." stays as typed and only commits when it parses.

Separator handling is where parseMoneyInput earns its 26 tests. With both "."
and "," present the LAST is the decimal point, which covers "1.234,56" and
"1,234.56" without knowing a locale. With one, it groups thousands if exactly
three digits follow and is a decimal point otherwise — so "1,500" is fifteen
hundred and "1,5" is one and a half. Spaces group thousands only in
three-digit groups, because "30 40" is two amounts typed by mistake and
committing it as 3040 would be worse than rejecting it. An unrecognised
currency returns null rather than falling back: reading "30 GBP" as 30 DKK
would book money at the wrong currency.

USER-DEFINED CURRENCIES. `Currency` was a closed union and decimal places were
a module constant, so "add a currency" was a code change. Both now live in the
dataset: `Dataset.currencies` is the authority on which codes are valid, what
they are called, their symbol, and how many decimals they round to. Settings
grows a table to add, edit and remove them.

That forced the money layer open. `roundMoney` takes a DIGIT COUNT, not a
currency code, and every one of its ~29 call sites resolves digits from the
dataset via `digitsFor`. Making digits a required number rather than an
optional one with a default of 2 is the whole safety argument: every site
became a compile error the type checker enumerated for me, where a default
would have left each one silently correct for DKK/USD/EUR and silently wrong
for the first zero-decimal currency the owner adds.

Because every existing currency uses two decimals, a broken version of this
refactor would still pass all 356 previous tests. src/domain/currencyDigits.test.ts
is the oracle that cannot lie: a JPY-style zero-decimal currency driven through
roundMoney, both FX directions, both split modes, finance-plan division, the
fold, and the month view, asserting no figure has a fractional yen in it.

Guards that come with it, all tested:
- Codes are identity — they key the FX table and every stored Money — so they
  are normalised (" jpy " -> "JPY") and cannot be edited, only added/removed.
- A currency in use cannot be removed, and the refusal names what refers to
  it. The same reasoning as posts being archived rather than deleted: stored
  Money carries the code, and dangling references would make the import
  validator refuse the owner's own exported file. Removing an unused one takes
  its exchange rate with it, leaving no orphan row.
- The import validator now checks the currency table itself — codes, duplicate
  codes, integer decimals 0-4 — and validates every Money against it. That
  boundary check replaces the compile-time safety the closed union provided.
- parseFxResponse takes the accepted codes instead of a global constant, so a
  rate service returning dozens cannot put unreachable rows in the table.

Migration v2 -> v3 seeds the currency table. Behaviour-preserving by
construction: every currency that existed had two decimal places and the
seeded table records two, so not one stored amount can round differently than
it did before. An existing `currencies` field is kept rather than overwritten,
since a hand-edited file could already carry currencies the owner added.

Verified in the browser, not just in tests: JPY added through the real Settings
form with 0 decimals, and the five inputs above typed into the real purchase
field, each setting the right currency.

Two obsolete test helpers went away. plans.test.ts and actions.test.ts used to
monkey-patch the CURRENCY_DIGITS constant to prove the table was consulted;
digits are an argument now, so those tests just ask for zero decimals.
types.test.ts no longer asserts "the supported currencies are exactly DKK,
USD, EUR" — that stopped being a property of the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 15:52:11 +02:00
1 parent 9243c8586b
commit cdece63c68
45 files changed
+1615 -309

No files matched your search

+14 -10
View File
@@ -2,26 +2,30 @@ import { test, expect, describe } from "bun:test";
import * as actions from "./actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import type { Dataset } from "../domain/types.ts";
import { CURRENCY_DIGITS } from "../domain/types.ts";
/**
* Every currency this app supports today has 2 decimal places, so code that
* hardcodes 2 is indistinguishable from code that reads CURRENCY_DIGITS. This
* forces the table for one test, which is the only way to prove the table is
* consulted without making the product decision to add a non-2dp currency.
* Digits now live in the dataset, so a currency with a different minor unit is
* expressed by DEFINING one — no monkey-patching a module constant, which is
* what this file used to need.
*/
function withDigits(currency: "DKK" | "USD" | "EUR", digits: number, body: () => void) {
const original = CURRENCY_DIGITS[currency];
CURRENCY_DIGITS[currency] = digits;
function withDigits(currency: string, digits: number, body: () => void) {
const original = zeroDigitDataset;
zeroDigitDataset = { currency, digits };
try {
body();
} finally {
CURRENCY_DIGITS[currency] = original;
zeroDigitDataset = original;
}
}
let zeroDigitDataset: { currency: string; digits: number } | null = null;
function draft(): Dataset {
return createSeedDataset("2026-09");
const data = createSeedDataset("2026-09");
if (zeroDigitDataset) {
const target = data.currencies.find((c) => c.code === zeroDigitDataset!.currency);
if (target) target.digits = zeroDigitDataset.digits;
}
return data;
}
describe("months", () => {
+84 -16
View File
@@ -1,8 +1,10 @@
import { newId } from "../domain/seed.ts";
import { compareMonths, monthOf } from "../domain/months.ts";
import { roundMoney } from "../domain/money.ts";
import { currencyUsage, digitsFor, normalizeCurrencyCode } from "../domain/currencies.ts";
import type {
Currency,
CurrencyDef,
Dataset,
FxRate,
Money,
@@ -46,13 +48,16 @@ function round2(value: number): number {
return Number(value.toFixed(2));
}
function roundMoneyValue(money: Money): Money {
return { amount: roundMoney(money.amount, money.currency), currency: money.currency };
function roundMoneyValue(draft: Dataset, money: Money): Money {
return {
amount: roundMoney(money.amount, digitsFor(draft.currencies, money.currency)),
currency: money.currency,
};
}
function roundRule(rule: Rule): Rule {
function roundRule(draft: Dataset, rule: Rule): Rule {
if (rule.kind === "fixed") {
return { kind: "fixed", amount: roundMoneyValue(rule.amount) };
return { kind: "fixed", amount: roundMoneyValue(draft, rule.amount) };
}
return { kind: "percentOfIncome", percent: round2(rule.percent) };
}
@@ -65,24 +70,25 @@ function roundRule(rule: Rule): Rule {
* currency happens to use hundredths.
*/
function roundSplits(
draft: Dataset,
splits: Split[],
splitMode: Purchase["splitMode"],
currency: Currency,
): Split[] {
const digits = digitsFor(draft.currencies, currency);
return splits.map((split) => ({
...split,
value:
splitMode === "fixed" ? roundMoney(split.value, currency) : round2(split.value),
value: splitMode === "fixed" ? roundMoney(split.value, digits) : round2(split.value),
}));
}
function roundSchedule(schedule: Schedule | null): Schedule | null {
function roundSchedule(draft: Dataset, schedule: Schedule | null): Schedule | null {
if (!schedule) return null;
return {
...schedule,
slices: schedule.slices.map((slice) => ({
month: slice.month,
amount: roundMoneyValue(slice.amount),
amount: roundMoneyValue(draft, slice.amount),
})),
};
}
@@ -101,7 +107,7 @@ export function ensureMonth(draft: Dataset, monthId: MonthId): Month {
}
export function setIncome(draft: Dataset, monthId: MonthId, income: Money): void {
ensureMonth(draft, monthId).income = roundMoneyValue(income);
ensureMonth(draft, monthId).income = roundMoneyValue(draft, income);
}
export function setRuleOverride(
@@ -115,7 +121,7 @@ export function setRuleOverride(
delete month.ruleOverrides[postId];
return;
}
month.ruleOverrides[postId] = roundRule(rule);
month.ruleOverrides[postId] = roundRule(draft, rule);
}
export function addPost(draft: Dataset, name: string, currency: Currency): Post {
@@ -173,7 +179,7 @@ export function setRuleFrom(
rule: Rule,
): void {
const post = requirePost(draft, postId);
const version: RuleVersion = { from, rule: roundRule(rule) };
const version: RuleVersion = { from, rule: roundRule(draft, rule) };
const existing = post.rules.findIndex((v) => v.from === from);
if (existing === -1) post.rules.push(version);
else post.rules[existing] = version;
@@ -196,9 +202,9 @@ export function addPurchase(
const created: Purchase = {
...purchase,
id: newId(),
total: roundMoneyValue(purchase.total),
splits: roundSplits(purchase.splits, purchase.splitMode, purchase.total.currency),
schedule: roundSchedule(purchase.schedule),
total: roundMoneyValue(draft, purchase.total),
splits: roundSplits(draft, purchase.splits, purchase.splitMode, purchase.total.currency),
schedule: roundSchedule(draft, purchase.schedule),
};
draft.purchases.push(created);
// Make sure every month the purchase touches exists, so income can be
@@ -218,20 +224,21 @@ export function updatePurchase(
const purchase = requirePurchase(draft, purchaseId);
const resolved: Partial<Omit<Purchase, "id">> = { ...changes };
if (changes.total) {
resolved.total = roundMoneyValue(changes.total);
resolved.total = roundMoneyValue(draft, changes.total);
}
if (changes.splits) {
// Either field may be absent from a partial update; the stored purchase
// supplies whichever one is, so the mode and currency always agree with
// the values being rounded.
resolved.splits = roundSplits(
draft,
changes.splits,
changes.splitMode ?? purchase.splitMode,
(resolved.total ?? purchase.total).currency,
);
}
if (changes.schedule !== undefined) {
resolved.schedule = roundSchedule(changes.schedule);
resolved.schedule = roundSchedule(draft, changes.schedule);
}
Object.assign(purchase, resolved);
}
@@ -257,6 +264,67 @@ export function cancelScheduleFrom(
* kept at six decimal places (see fxApi.ts's toFixed(6)), and rounding to a
* currency's minor unit would quantize a rate below 0.01 straight to zero.
*/
/** Codes are identity, so they are validated the same way on every path. */
const CURRENCY_CODE = /^[A-Z]{2,8}$/;
function requireCurrencyDef(def: CurrencyDef): CurrencyDef {
const code = normalizeCurrencyCode(def.code);
if (!CURRENCY_CODE.test(code)) {
throw new Error(`Currency code "${def.code}" must be 2-8 letters`);
}
if (!Number.isInteger(def.digits) || def.digits < 0 || def.digits > 4) {
throw new Error(
`Currency "${code}" needs a whole number of decimal places between 0 and 4`,
);
}
return {
code,
digits: def.digits,
...(def.symbol?.trim() ? { symbol: def.symbol.trim() } : {}),
...(def.name?.trim() ? { name: def.name.trim() } : {}),
};
}
export function addCurrency(draft: Dataset, def: CurrencyDef): CurrencyDef {
const currency = requireCurrencyDef(def);
if (draft.currencies.some((existing) => existing.code === currency.code)) {
throw new Error(`Currency ${currency.code} is already defined`);
}
draft.currencies.push(currency);
return currency;
}
export function updateCurrency(
draft: Dataset,
code: Currency,
changes: Partial<Omit<CurrencyDef, "code">>,
): void {
const existing = draft.currencies.find((currency) => currency.code === code);
if (!existing) throw new Error(`Unknown currency: ${code}`);
// The code is identity — it keys the FX table and every stored Money — so it
// is deliberately not editable. Renaming one means adding the new code,
// moving what refers to it, and removing the old.
Object.assign(existing, requireCurrencyDef({ ...existing, ...changes, code }));
}
/**
* Refuses to remove a currency anything still refers to, naming what refers to
* it. Its exchange rate goes with it, so no orphan row survives.
*/
export function removeCurrency(draft: Dataset, code: Currency): void {
const used = currencyUsage(draft, code);
if (used.length > 0) {
const isBase = draft.settings.baseCurrency === code;
throw new Error(
isBase
? `${code} is the base currency; choose another base before removing it`
: `${code} is in use by ${used.slice(0, 3).join(", ")}${used.length > 3 ? ` and ${used.length - 3} more` : ""}`,
);
}
draft.currencies = draft.currencies.filter((currency) => currency.code !== code);
draft.fxRates = draft.fxRates.filter((rate) => rate.currency !== code);
}
export function setFxRate(draft: Dataset, rate: FxRate): void {
const index = draft.fxRates.findIndex((r) => r.currency === rate.currency);
if (index === -1) draft.fxRates.push(rate);
+6 -1
View File
@@ -70,7 +70,12 @@ describe("what baked rates are allowed to do", () => {
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),
toBase(
{ amount: 100, currency: "EUR" },
data.settings.baseCurrency,
data.fxRates,
data.currencies,
),
).toThrow(MissingRateError);
});
});
+9 -4
View File
@@ -1,4 +1,4 @@
import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts";
import { SEED_CURRENCIES, type Currency, type FxRate } from "../domain/types.ts";
/**
* Exchange rates baked in at build time so a fresh install can convert a EUR
@@ -39,14 +39,19 @@ export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([
}),
]);
/** The currencies a baked payload must cover: everything but the base. */
const REQUIRED = CURRENCIES.filter((c) => c !== "DKK");
/**
* The currencies a baked payload must cover: the seed table minus the base.
* Only the SEED currencies — a dataset that already exists is never seeded, so
* currencies the owner added later are irrelevant here.
*/
const REQUIRED: Currency[] = SEED_CURRENCIES.map((c) => c.code).filter((c) => c !== "DKK");
const SEED_CODES: Currency[] = SEED_CURRENCIES.map((c) => c.code);
function isUsableRate(value: unknown): value is FxRate {
if (typeof value !== "object" || value === null) return false;
const rate = value as Record<string, unknown>;
return (
CURRENCIES.includes(rate.currency as Currency) &&
SEED_CODES.includes(rate.currency as Currency) &&
typeof rate.baseUnitsPerOne === "number" &&
Number.isFinite(rate.baseUnitsPerOne) &&
rate.baseUnitsPerOne > 0 &&
+128
View File
@@ -0,0 +1,128 @@
import { test, expect, describe } from "bun:test";
import * as actions from "./actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import { currencyUsage } from "../domain/currencies.ts";
import type { Dataset } from "../domain/types.ts";
const draft = (): Dataset => createSeedDataset("2026-09");
describe("adding a currency", () => {
test("appends it with the digits and symbol given", () => {
const data = draft();
actions.addCurrency(data, { code: "JPY", digits: 0, symbol: "¥", name: "Japanese yen" });
expect(data.currencies.at(-1)).toEqual({
code: "JPY",
digits: 0,
symbol: "¥",
name: "Japanese yen",
});
});
test("the code is normalised, because a code is identity", () => {
// "usd" and "USD" must never become two currencies: the code keys the FX
// table and every stored Money.
const data = draft();
actions.addCurrency(data, { code: " jpy ", digits: 0 });
expect(data.currencies.at(-1)!.code).toBe("JPY");
});
test("a duplicate is refused rather than shadowing the original", () => {
const data = draft();
expect(() => actions.addCurrency(data, { code: "usd", digits: 2 })).toThrow(/USD/);
expect(data.currencies.filter((c) => c.code === "USD")).toHaveLength(1);
});
test.each([
["not letters", "US1"],
["too short", "U"],
["empty", " "],
])("a code that is %s is refused", (_label, code) => {
expect(() => actions.addCurrency(draft(), { code, digits: 2 })).toThrow();
});
test.each([[-1], [5], [1.5]])("invalid decimal places (%p) are refused", (digits) => {
expect(() => actions.addCurrency(draft(), { code: "JPY", digits })).toThrow(/decimal/i);
});
});
describe("editing a currency", () => {
test("changes the digits, symbol and name but never the code", () => {
const data = draft();
actions.updateCurrency(data, "USD", { digits: 3, symbol: "US$" });
const usd = data.currencies.find((c) => c.code === "USD")!;
expect(usd).toEqual({ code: "USD", digits: 3, symbol: "US$", name: "US dollar" });
});
test("an unknown code throws", () => {
expect(() => actions.updateCurrency(draft(), "JPY", { digits: 0 })).toThrow(/JPY/);
});
});
describe("removing a currency", () => {
test("removes one nothing references", () => {
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"]);
});
test("its exchange rate goes with it, so no orphan row is left behind", () => {
const data = draft();
actions.addCurrency(data, { code: "JPY", digits: 0 });
actions.setFxRate(data, {
currency: "JPY",
baseUnitsPerOne: 0.045,
updatedAt: "2026-09-01",
source: "manual",
});
actions.removeCurrency(data, "JPY");
expect(data.fxRates.find((r) => r.currency === "JPY")).toBeUndefined();
});
test("the base currency cannot be removed", () => {
expect(() => actions.removeCurrency(draft(), "DKK")).toThrow(/base/i);
});
/**
* The same reasoning as posts being archived rather than deleted: stored
* Money references the code. Removing a currency in use would leave amounts
* pointing at a definition that no longer exists — and the import validator
* would then refuse the owner's own exported file.
*/
test("a currency still referenced by a purchase cannot be removed", () => {
const data = draft();
actions.addPurchase(data, {
date: "2026-09-04",
description: "Indie bundle",
total: { amount: 27.5, currency: "EUR" },
splitMode: "percent",
splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
schedule: null,
});
expect(() => actions.removeCurrency(data, "EUR")).toThrow(/in use/i);
});
test("a currency referenced only by a post's display setting cannot be removed", () => {
const data = draft();
actions.updatePost(data, data.posts[0]!.id, { currency: "USD" });
expect(() => actions.removeCurrency(data, "USD")).toThrow(/in use/i);
});
});
describe("currencyUsage", () => {
test("names where a currency is referenced, so the UI can say why", () => {
const data = draft();
actions.setIncome(data, "2026-10", { amount: 100, currency: "USD" });
expect(currencyUsage(data, "USD")).toContain("month 2026-10");
expect(currencyUsage(data, "JPY")).toEqual([]);
});
test("a fixed allocation rule counts as a reference", () => {
const data = draft();
actions.setRuleFrom(data, data.posts[0]!.id, "2026-09", {
kind: "fixed",
amount: { amount: 50, currency: "EUR" },
});
expect(currencyUsage(data, "EUR").join(" ")).toMatch(/rule/i);
});
});
+11 -8
View File
@@ -1,6 +1,9 @@
import { test, expect, describe } from "bun:test";
import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./fxApi.ts";
/** What the caller asked the service for, and so what it will accept back. */
const ALLOWED = ["DKK", "USD", "EUR"];
describe("buildFxUrl", () => {
test("substitutes the base and target placeholders", () => {
expect(buildFxUrl(DEFAULT_FX_API_URL, "DKK", ["USD", "EUR"])).toBe(
@@ -13,36 +16,36 @@ describe("parseFxResponse", () => {
test("inverts the rates into base units per one unit", () => {
// The API returns how many target units 1 base unit buys; we store the
// inverse, so 1 EUR = 7.46 DKK becomes baseUnitsPerOne 7.46.
const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK");
const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK", ALLOWED);
expect(rates.find((r) => r.currency === "EUR")!.baseUnitsPerOne).toBeCloseTo(7.46, 2);
expect(rates.every((r) => r.source === "api")).toBe(true);
expect(rates.every((r) => r.updatedAt !== "")).toBe(true);
});
test("skips the base currency's own row if the API returns one", () => {
const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK");
const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK", ALLOWED);
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
});
test("ignores unsupported currencies", () => {
const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK");
test("ignores currencies this dataset does not define", () => {
const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK", ALLOWED);
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
});
test("rejects a response with no rates object", () => {
expect(() => parseFxResponse({}, "DKK")).toThrow(/rates/);
expect(() => parseFxResponse("nope", "DKK")).toThrow(/rates/);
expect(() => parseFxResponse({}, "DKK", ALLOWED)).toThrow(/rates/);
expect(() => parseFxResponse("nope", "DKK", ALLOWED)).toThrow(/rates/);
});
test("rejects a zero or negative rate rather than storing an infinity", () => {
expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK")).toThrow(/EUR/);
expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK", ALLOWED)).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");
const rates = parseFxResponse({ rates: { EUR: 0.13378, USD: 0.15505 } }, "DKK", ALLOWED, "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);
});
+10 -3
View File
@@ -1,4 +1,4 @@
import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts";
import type { Currency, FxRate } from "../domain/types.ts";
/** Frankfurter needs no API key. The URL is user-editable in settings. */
export const DEFAULT_FX_API_URL = "https://api.frankfurter.app/latest?from={base}&to={targets}";
@@ -16,6 +16,12 @@ export function buildFxUrl(
export function parseFxResponse(
body: unknown,
base: Currency,
/**
* The currencies this dataset knows. Anything else in the response is
* skipped: a rate service returns dozens, and storing one for a currency the
* owner has not defined would put an unreachable row in their table.
*/
allowed: readonly Currency[],
/** Defaults to now. The build passes its own date when baking rates in. */
updatedAt: string = new Date().toISOString(),
): FxRate[] {
@@ -31,7 +37,7 @@ export function parseFxResponse(
for (const [currency, value] of Object.entries(rates as Record<string, unknown>)) {
if (currency === base) continue;
if (!CURRENCIES.includes(currency as Currency)) continue;
if (!allowed.includes(currency)) continue;
if (typeof value !== "number" || !(value > 0)) {
throw new Error(`Exchange rate for ${currency} is not a positive number`);
}
@@ -56,5 +62,6 @@ export async function fetchFxRates(
if (!response.ok) {
throw new Error(`Rate service returned ${response.status}`);
}
return parseFxResponse(await response.json(), base);
// The targets we asked for are exactly the ones we will accept back.
return parseFxResponse(await response.json(), base, targets);
}
+100 -2
View File
@@ -1,6 +1,6 @@
import { test, expect, describe } from "bun:test";
import { SCHEMA_VERSION, createSeedDataset } from "../domain/seed.ts";
import { migrate, UnsupportedSchemaError } from "./migrations.ts";
import { createSeedDataset, SCHEMA_VERSION } from "../domain/seed.ts";
describe("migrate", () => {
test("passes a current-version dataset through unchanged", () => {
@@ -63,7 +63,10 @@ function v1Dataset() {
describe("v1 -> v2: rules become a dated series", () => {
test("each standingRule becomes one version starting at the fold start", () => {
const migrated = migrate(v1Dataset());
expect(migrated.settings.schemaVersion).toBe(2);
// migrate() runs every step to the current SCHEMA_VERSION, so this lands
// at 3 now that currencies became data. The rule conversion below is what
// this test is actually about.
expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION);
expect(migrated.posts[0]!.rules).toEqual([
{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 20 } },
]);
@@ -135,3 +138,98 @@ describe("v1 -> v2: rules become a dated series", () => {
]);
});
});
describe("v2 -> v3: currencies become data", () => {
/** A realistic v2 dataset: no `currencies` field existed. */
function v2(): any {
return {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 },
fxRates: [
{ currency: "EUR", baseUnitsPerOne: 7.4, updatedAt: "2026-01-01", source: "manual" },
],
posts: [
{
id: "p1",
name: "Food",
order: 0,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 12 } }],
},
],
months: [
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
],
purchases: [],
};
}
test("the three shipped currencies are added, with their decimals", () => {
const migrated = migrate(v2());
expect(migrated.settings.schemaVersion).toBe(3);
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" },
]);
});
/**
* The whole point of the migration being safe: every currency that existed
* had two decimal places, and the seeded table says two, so not one stored
* amount can round differently than it did before.
*/
test("no amount can round differently than it did under v2", () => {
const migrated = migrate(v2());
for (const currency of migrated.currencies) {
expect(currency.digits).toBe(2);
}
});
test("nothing else about the dataset is touched", () => {
const before = v2();
const migrated = migrate(structuredClone(before));
expect(migrated.posts).toEqual(before.posts);
expect(migrated.months).toEqual(before.months);
expect(migrated.fxRates).toEqual(before.fxRates);
expect(migrated.settings.baseCurrency).toBe("DKK");
expect(migrated.settings.foldStartMonth).toBe("2026-01");
});
test("a v1 dataset migrates all the way through to v3", () => {
const v1 = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
fxRates: [],
posts: [
{
id: "p1",
name: "Food",
order: 0,
archived: false,
currency: "DKK",
standingRule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } },
},
],
months: [],
purchases: [],
};
const migrated = migrate(v1);
expect(migrated.settings.schemaVersion).toBe(3);
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"]);
});
/**
* A dataset can only reach v3 through this step, but an owner could have a
* hand-edited file that already has the field. Overwriting it would silently
* discard currencies they added.
*/
test("an existing currencies field is kept, not overwritten", () => {
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"]);
});
});
+19
View File
@@ -1,4 +1,5 @@
import { SCHEMA_VERSION } from "../domain/seed.ts";
import { SEED_CURRENCIES } from "../domain/types.ts";
import type { Dataset } from "../domain/types.ts";
export class UnsupportedSchemaError extends Error {
@@ -37,6 +38,24 @@ const MIGRATIONS: Array<(data: any) => any> = [
};
}),
}),
// 2 -> 3: currencies become data. Until now the three supported currencies
// and their decimal places were compile-time constants, so adding one meant
// changing code; they now live in the dataset, which is also what lets the
// owner define their own.
//
// Behaviour-preserving by construction: every currency that existed had two
// decimal places, and the seeded table records two, so not one stored amount
// can round differently than it did before. An existing `currencies` field
// is left alone rather than overwritten — a hand-edited file could already
// carry currencies the owner added, and discarding them would be data loss.
(data: any) => ({
...data,
settings: { ...data.settings, schemaVersion: 3 },
currencies: Array.isArray(data.currencies)
? data.currencies
: SEED_CURRENCIES.map((currency) => ({ ...currency })),
}),
];
export function migrate(raw: unknown): Dataset {