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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9243c8586b
commit
cdece63c68
@@ -47,7 +47,7 @@ async function bakeFxRates(): Promise<readonly FxRate[]> {
|
||||
});
|
||||
if (!response.ok) throw new Error(`rate service returned ${response.status}`);
|
||||
|
||||
const rates = parseFxResponse(await response.json(), FX_BASE, buildDate);
|
||||
const rates = parseFxResponse(await response.json(), FX_BASE, FX_TARGETS, 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.
|
||||
|
||||
@@ -10,6 +10,13 @@ import {
|
||||
import { foldBalances, figuresFor } from "./fold.ts";
|
||||
import type { Dataset, FxRate, Post, Rule } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
const rates: FxRate[] = [
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
|
||||
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
|
||||
@@ -36,6 +43,7 @@ function post(overrides: Partial<Post> = {}): Post {
|
||||
function dataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: rates,
|
||||
posts: [post()],
|
||||
months: [
|
||||
@@ -48,21 +56,21 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
|
||||
describe("resolveRule", () => {
|
||||
test("a fixed rule in base currency is its amount", () => {
|
||||
expect(resolveRule(fixed(400), 20000, "DKK", rates)).toBe(400);
|
||||
expect(resolveRule(fixed(400), 20000, "DKK", rates, TEST_CURRENCIES)).toBe(400);
|
||||
});
|
||||
|
||||
test("a fixed rule in a foreign currency converts to base", () => {
|
||||
expect(
|
||||
resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates),
|
||||
resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates, TEST_CURRENCIES),
|
||||
).toBe(373);
|
||||
});
|
||||
|
||||
test("a percentage rule resolves against income", () => {
|
||||
expect(resolveRule(pct(25), 20000, "DKK", rates)).toBe(5000);
|
||||
expect(resolveRule(pct(25), 20000, "DKK", rates, TEST_CURRENCIES)).toBe(5000);
|
||||
});
|
||||
|
||||
test("a percentage above 100 is permitted", () => {
|
||||
expect(resolveRule(pct(150), 1000, "DKK", rates)).toBe(1500);
|
||||
expect(resolveRule(pct(150), 1000, "DKK", rates, TEST_CURRENCIES)).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,6 +185,7 @@ describe("the fold across a rule change", () => {
|
||||
// rollover carrying correctly across the boundary.
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: [],
|
||||
posts: [
|
||||
post({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { toBase } from "./fx.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import { compareMonths } from "./months.ts";
|
||||
import type {
|
||||
Currency,
|
||||
CurrencyDef,
|
||||
Dataset,
|
||||
FxRate,
|
||||
Month,
|
||||
@@ -19,11 +21,12 @@ export function resolveRule(
|
||||
income: number,
|
||||
baseCurrency: Currency,
|
||||
rates: FxRate[],
|
||||
currencies: readonly CurrencyDef[],
|
||||
): number {
|
||||
if (rule.kind === "fixed") {
|
||||
return toBase(rule.amount, baseCurrency, rates);
|
||||
return toBase(rule.amount, baseCurrency, rates, currencies);
|
||||
}
|
||||
return roundMoney((income * rule.percent) / 100, baseCurrency);
|
||||
return roundMoney((income * rule.percent) / 100, digitsFor(currencies, baseCurrency));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +68,12 @@ export function isOverridden(month: Month | undefined, postId: PostId): boolean
|
||||
export function incomeFor(dataset: Dataset, monthId: MonthId): number {
|
||||
const month = dataset.months.find((m) => m.id === monthId);
|
||||
if (!month) return 0;
|
||||
return toBase(month.income, dataset.settings.baseCurrency, dataset.fxRates);
|
||||
return toBase(
|
||||
month.income,
|
||||
dataset.settings.baseCurrency,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
}
|
||||
|
||||
export function allocationFor(
|
||||
@@ -83,5 +91,6 @@ export function allocationFor(
|
||||
incomeFor(dataset, monthId),
|
||||
dataset.settings.baseCurrency,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
}
|
||||
|
||||
+25
-13
@@ -8,6 +8,13 @@ import {
|
||||
import { roundMoney } from "./money.ts";
|
||||
import type { Dataset, FxRate, Purchase } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
const rates: FxRate[] = [
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
|
||||
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
|
||||
@@ -28,7 +35,7 @@ function purchase(overrides: Partial<Purchase> = {}): Purchase {
|
||||
|
||||
describe("splitPartsOfTotal", () => {
|
||||
test("a single split takes the whole total", () => {
|
||||
expect(splitPartsOfTotal(purchase())).toEqual([200]);
|
||||
expect(splitPartsOfTotal(purchase(), 2)).toEqual([200]);
|
||||
});
|
||||
|
||||
test("percent mode divides proportionally", () => {
|
||||
@@ -39,7 +46,8 @@ describe("splitPartsOfTotal", () => {
|
||||
{ postId: "events", value: 40, absorbsRemainder: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
2,
|
||||
);
|
||||
expect(parts).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
@@ -52,7 +60,8 @@ describe("splitPartsOfTotal", () => {
|
||||
{ postId: "events", value: 50, absorbsRemainder: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
2,
|
||||
);
|
||||
expect(parts).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
@@ -66,8 +75,9 @@ describe("splitPartsOfTotal", () => {
|
||||
{ postId: "c", value: 33.34, absorbsRemainder: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(100);
|
||||
2,
|
||||
);
|
||||
expect(roundMoney(parts.reduce((a, b) => a + b, 0), 2)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,14 +131,14 @@ describe("sliceAmountForMonth", () => {
|
||||
|
||||
describe("chargesForPurchaseInMonth", () => {
|
||||
test("a simple purchase charges one post", () => {
|
||||
expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "food", amount: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("converts a foreign-currency purchase into base", () => {
|
||||
const eur = purchase({ total: { amount: 10, currency: "EUR" } });
|
||||
expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "food", amount: 74.6 },
|
||||
]);
|
||||
});
|
||||
@@ -147,11 +157,11 @@ describe("chargesForPurchaseInMonth", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "games", amount: 700 },
|
||||
{ postId: "events", amount: 300 },
|
||||
]);
|
||||
expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "games", amount: 1400 },
|
||||
{ postId: "events", amount: 600 },
|
||||
]);
|
||||
@@ -169,14 +179,14 @@ describe("chargesForPurchaseInMonth", () => {
|
||||
slices: [{ month: "2026-10", amount: { amount: 600, currency: "DKK" } }],
|
||||
},
|
||||
});
|
||||
expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "games", amount: 400 },
|
||||
{ postId: "events", amount: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("charges in a month the purchase does not touch are empty", () => {
|
||||
expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates)).toEqual([]);
|
||||
expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates, TEST_CURRENCIES)).toEqual([]);
|
||||
});
|
||||
|
||||
test("slice charges always sum to the slice total", () => {
|
||||
@@ -188,8 +198,8 @@ describe("chargesForPurchaseInMonth", () => {
|
||||
{ postId: "c", value: 1, absorbsRemainder: true },
|
||||
],
|
||||
});
|
||||
const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates);
|
||||
expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0))).toBe(100);
|
||||
const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates, TEST_CURRENCIES);
|
||||
expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0), 2)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,6 +207,7 @@ describe("chargesForMonth", () => {
|
||||
test("sums charges per post across all purchases", () => {
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: rates,
|
||||
posts: [],
|
||||
months: [],
|
||||
@@ -214,6 +225,7 @@ describe("chargesForMonth", () => {
|
||||
test("a month with no purchases yields an empty map", () => {
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: rates,
|
||||
posts: [],
|
||||
months: [],
|
||||
|
||||
+15
-8
@@ -1,8 +1,10 @@
|
||||
import { toBase } from "./fx.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import { distributeByAmount, distributeByWeight, roundMoney } from "./money.ts";
|
||||
import { compareMonths, monthOf } from "./months.ts";
|
||||
import type {
|
||||
Currency,
|
||||
CurrencyDef,
|
||||
Dataset,
|
||||
FxRate,
|
||||
Money,
|
||||
@@ -31,14 +33,13 @@ export function remainderIndexOf(purchase: Purchase): number {
|
||||
* How the whole purchase divides across its posts, in the purchase's own
|
||||
* currency. Parts sum exactly to the total.
|
||||
*/
|
||||
export function splitPartsOfTotal(purchase: Purchase): number[] {
|
||||
export function splitPartsOfTotal(purchase: Purchase, digits: number): number[] {
|
||||
const index = remainderIndexOf(purchase);
|
||||
const values = purchase.splits.map((s) => s.value);
|
||||
const total = purchase.total.amount;
|
||||
const currency = purchase.total.currency;
|
||||
return purchase.splitMode === "percent"
|
||||
? distributeByWeight(total, values, index, currency)
|
||||
: distributeByAmount(total, values, index, currency);
|
||||
? distributeByWeight(total, values, index, digits)
|
||||
: distributeByAmount(total, values, index, digits);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,14 +73,18 @@ export function chargesForPurchaseInMonth(
|
||||
monthId: MonthId,
|
||||
baseCurrency: Currency,
|
||||
rates: FxRate[],
|
||||
currencies: readonly CurrencyDef[],
|
||||
): Charge[] {
|
||||
const slice = sliceAmountForMonth(purchase, monthId);
|
||||
if (!slice) return [];
|
||||
|
||||
const sliceBase = toBase(slice, baseCurrency, rates);
|
||||
const sliceBase = toBase(slice, baseCurrency, rates, currencies);
|
||||
const index = remainderIndexOf(purchase);
|
||||
const weights = splitPartsOfTotal(purchase);
|
||||
const parts = distributeByWeight(sliceBase, weights, index, baseCurrency);
|
||||
// Split proportions are computed in the PURCHASE's own currency, then the
|
||||
// month's slice is distributed to those proportions in the BASE currency —
|
||||
// so the two roundings use different digits.
|
||||
const weights = splitPartsOfTotal(purchase, digitsFor(currencies, purchase.total.currency));
|
||||
const parts = distributeByWeight(sliceBase, weights, index, digitsFor(currencies, baseCurrency));
|
||||
|
||||
return purchase.splits.map((split, i) => ({
|
||||
postId: split.postId,
|
||||
@@ -93,18 +98,20 @@ export function chargesForMonth(
|
||||
monthId: MonthId,
|
||||
): Map<PostId, number> {
|
||||
const totals = new Map<PostId, number>();
|
||||
const baseDigits = digitsFor(dataset.currencies, dataset.settings.baseCurrency);
|
||||
for (const purchase of dataset.purchases) {
|
||||
const charges = chargesForPurchaseInMonth(
|
||||
purchase,
|
||||
monthId,
|
||||
dataset.settings.baseCurrency,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
for (const charge of charges) {
|
||||
const previous = totals.get(charge.postId) ?? 0;
|
||||
totals.set(
|
||||
charge.postId,
|
||||
roundMoney(previous + charge.amount, dataset.settings.baseCurrency),
|
||||
roundMoney(previous + charge.amount, baseDigits),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { digitsFor, currencyCodes, findCurrency, normalizeCurrencyCode } from "./currencies.ts";
|
||||
import type { CurrencyDef } from "./types.ts";
|
||||
|
||||
const TABLE: CurrencyDef[] = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "JPY", digits: 0, symbol: "¥" },
|
||||
{ code: "KWD", digits: 3 },
|
||||
];
|
||||
|
||||
describe("digitsFor", () => {
|
||||
test("reads the digits the owner recorded, not an assumed 2", () => {
|
||||
expect(digitsFor(TABLE, "DKK")).toBe(2);
|
||||
expect(digitsFor(TABLE, "JPY")).toBe(0);
|
||||
expect(digitsFor(TABLE, "KWD")).toBe(3);
|
||||
});
|
||||
|
||||
/**
|
||||
* Deep inside the fold a throw would take the whole balance view down over
|
||||
* a data problem the import validator already rejects at the boundary. Two
|
||||
* places is right for almost every currency in existence.
|
||||
*/
|
||||
test("an unknown currency falls back to two places rather than throwing", () => {
|
||||
expect(digitsFor(TABLE, "XYZ")).toBe(2);
|
||||
expect(digitsFor([], "DKK")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCurrencyCode", () => {
|
||||
test("codes are identity, so case and padding cannot fork one currency in two", () => {
|
||||
expect(normalizeCurrencyCode(" usd ")).toBe("USD");
|
||||
expect(normalizeCurrencyCode("Jpy")).toBe("JPY");
|
||||
});
|
||||
});
|
||||
|
||||
test("currencyCodes keeps the owner's order", () => {
|
||||
expect(currencyCodes(TABLE)).toEqual(["DKK", "JPY", "KWD"]);
|
||||
});
|
||||
|
||||
test("findCurrency returns the definition, or nothing", () => {
|
||||
expect(findCurrency(TABLE, "JPY")?.symbol).toBe("¥");
|
||||
expect(findCurrency(TABLE, "XYZ")).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
DEFAULT_CURRENCY_DIGITS,
|
||||
type Currency,
|
||||
type CurrencyDef,
|
||||
type Dataset,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* How many decimals a currency rounds to.
|
||||
*
|
||||
* Digits used to be a module-level constant table, which made "add a
|
||||
* currency" a code change. They now live in the dataset, so every rounding
|
||||
* site has to be told which digits to use — that threading is deliberate, and
|
||||
* the reason `roundMoney` takes a number rather than a code.
|
||||
*
|
||||
* An undefined currency falls back to two places rather than throwing. A
|
||||
* missing definition is a data problem the import validator rejects at the
|
||||
* boundary; deep inside the fold, two decimals is right for almost every
|
||||
* currency and a throw would take the whole balance view down.
|
||||
*/
|
||||
export function digitsFor(
|
||||
currencies: readonly CurrencyDef[],
|
||||
code: Currency,
|
||||
): number {
|
||||
const found = currencies.find((currency) => currency.code === code);
|
||||
return found ? found.digits : DEFAULT_CURRENCY_DIGITS;
|
||||
}
|
||||
|
||||
/** The codes, in the order the owner arranged them. */
|
||||
export function currencyCodes(currencies: readonly CurrencyDef[]): Currency[] {
|
||||
return currencies.map((currency) => currency.code);
|
||||
}
|
||||
|
||||
export function findCurrency(
|
||||
currencies: readonly CurrencyDef[],
|
||||
code: Currency,
|
||||
): CurrencyDef | undefined {
|
||||
return currencies.find((currency) => currency.code === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uppercased and trimmed. Codes are identity here — they key the FX table and
|
||||
* every stored Money — so "usd" and "USD" must never become two currencies.
|
||||
*/
|
||||
export function normalizeCurrencyCode(raw: string): Currency {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Everywhere `code` is referenced by stored data, described for a human.
|
||||
*
|
||||
* The same reasoning as posts being archived rather than deleted: stored Money
|
||||
* carries the code, so removing a currency in use would leave amounts pointing
|
||||
* at a definition that no longer exists — and `parseDatasetJson` would then
|
||||
* refuse the owner's own exported file. Returning WHERE it is used lets the UI
|
||||
* explain the refusal instead of just denying it.
|
||||
*/
|
||||
export function currencyUsage(dataset: Dataset, code: Currency): string[] {
|
||||
const used: string[] = [];
|
||||
|
||||
if (dataset.settings.baseCurrency === code) used.push("the base currency");
|
||||
|
||||
for (const post of dataset.posts) {
|
||||
if (post.currency === code) used.push(`post "${post.name}"`);
|
||||
for (const version of post.rules) {
|
||||
if (version.rule.kind === "fixed" && version.rule.amount.currency === code) {
|
||||
used.push(`post "${post.name}" rule from ${version.from}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const month of dataset.months) {
|
||||
if (month.income.currency === code) used.push(`month ${month.id}`);
|
||||
for (const [postId, rule] of Object.entries(month.ruleOverrides)) {
|
||||
if (rule.kind === "fixed" && rule.amount.currency === code) {
|
||||
const name = dataset.posts.find((p) => p.id === postId)?.name ?? postId;
|
||||
used.push(`${month.id} override for "${name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const purchase of dataset.purchases) {
|
||||
if (purchase.total.currency === code) used.push(`purchase "${purchase.description}"`);
|
||||
for (const slice of purchase.schedule?.slices ?? []) {
|
||||
if (slice.amount.currency === code) {
|
||||
used.push(`purchase "${purchase.description}" slice ${slice.month}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(used)];
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { roundMoney, distributeByWeight, distributeByAmount } from "./money.ts";
|
||||
import { toBase, fromBase } from "./fx.ts";
|
||||
import { equalSlices, sliceTotal } from "./plans.ts";
|
||||
import { resolveRule } from "./allocation.ts";
|
||||
import { foldBalances, figuresFor } from "./fold.ts";
|
||||
import { monthView } from "./views.ts";
|
||||
import type { CurrencyDef, Dataset } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Every currency the app shipped with has two decimal places, so code that
|
||||
* hardcodes 2 is indistinguishable from code that reads the dataset. Now that
|
||||
* the owner can define currencies, that stops being true — and a zero-decimal
|
||||
* currency is the only thing that can prove digits actually flow from the
|
||||
* dataset to each rounding site rather than being assumed.
|
||||
*
|
||||
* JPY is the real-world case: yen have no minor unit, so ¥3333.3 is not a
|
||||
* number that can exist.
|
||||
*/
|
||||
const JPY: CurrencyDef = { code: "JPY", digits: 0, symbol: "¥" };
|
||||
const USD: CurrencyDef = { code: "USD", digits: 2, symbol: "$" };
|
||||
const CURRENCIES = [JPY, USD];
|
||||
|
||||
describe("rounding takes its digits from the dataset", () => {
|
||||
test("roundMoney rounds to the digits it is given", () => {
|
||||
expect(roundMoney(3333.3, 0)).toBe(3333);
|
||||
expect(roundMoney(3333.335, 2)).toBe(3333.34);
|
||||
// 1.234, not 1.235: the double nearest 1.2345 is slightly BELOW it, and
|
||||
// toFixed rounds the double's actual value. That is the documented
|
||||
// trade-off in money.ts — predictable, if occasionally surprising.
|
||||
expect(roundMoney(1.2345, 3)).toBe(1.234);
|
||||
});
|
||||
|
||||
test("a percentage allocation in a zero-decimal currency yields whole units", () => {
|
||||
const rate = resolveRule(
|
||||
{ kind: "percentOfIncome", percent: 33.333 },
|
||||
10_000,
|
||||
"JPY",
|
||||
[],
|
||||
CURRENCIES,
|
||||
);
|
||||
expect(rate).toBe(3333);
|
||||
expect(Number.isInteger(rate)).toBe(true);
|
||||
});
|
||||
|
||||
test("an FX conversion into a zero-decimal base yields whole units", () => {
|
||||
const rates = [
|
||||
{ currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" as const },
|
||||
];
|
||||
expect(toBase({ amount: 10, currency: "USD" }, "JPY", rates, CURRENCIES)).toBe(1512);
|
||||
// And back out: 1512 / 151.234567 = 9.9977..., rounded to USD's TWO
|
||||
// decimals, so 10 — not the three-decimal 9.998.
|
||||
expect(fromBase(1512, "USD", "JPY", rates, CURRENCIES)).toBe(10);
|
||||
});
|
||||
|
||||
test("a split distributed in a zero-decimal currency still sums exactly", () => {
|
||||
const parts = distributeByWeight(1000, [1, 1, 1], 2, 0);
|
||||
expect(parts).toEqual([333, 333, 334]);
|
||||
expect(parts.reduce((a, b) => a + b, 0)).toBe(1000);
|
||||
expect(parts.every(Number.isInteger)).toBe(true);
|
||||
});
|
||||
|
||||
test("fixed amounts distributed in a zero-decimal currency still sum exactly", () => {
|
||||
const parts = distributeByAmount(1000, [333.4, 333.4, 0], 2, 0);
|
||||
expect(parts.reduce((a, b) => a + b, 0)).toBe(1000);
|
||||
expect(parts.every(Number.isInteger)).toBe(true);
|
||||
});
|
||||
|
||||
test("a finance plan divides into whole units and sums exactly", () => {
|
||||
const slices = equalSlices({ amount: 1000, currency: "JPY" }, "2026-01", 3, 0);
|
||||
expect(slices.map((s) => s.amount.amount)).toEqual([333, 333, 334]);
|
||||
expect(sliceTotal(slices, 0)).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the fold in a zero-decimal currency", () => {
|
||||
function dataset(): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "JPY", foldStartMonth: "2026-01", schemaVersion: 3 },
|
||||
currencies: CURRENCIES,
|
||||
fxRates: [],
|
||||
posts: [
|
||||
{
|
||||
id: "food",
|
||||
name: "Food",
|
||||
order: 0,
|
||||
archived: false,
|
||||
currency: "JPY",
|
||||
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 33.333 } }],
|
||||
},
|
||||
],
|
||||
months: [
|
||||
{ id: "2026-01", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
|
||||
{ id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("no figure the fold produces has a fractional yen in it", () => {
|
||||
const fold = foldBalances(dataset(), "2026-02");
|
||||
for (const month of ["2026-01", "2026-02"]) {
|
||||
const figures = figuresFor(fold, "food", month);
|
||||
for (const [name, value] of Object.entries(figures)) {
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
expect(`${month} ${name} = ${value}`).toBe(`${month} ${name} = ${Math.round(value)}`);
|
||||
}
|
||||
}
|
||||
expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333);
|
||||
expect(figuresFor(fold, "food", "2026-02").carriedIn).toBe(3333);
|
||||
});
|
||||
|
||||
test("the month view's totals are whole units too", () => {
|
||||
const view = monthView(dataset(), "2026-01");
|
||||
expect(Number.isInteger(view.totalAllocation)).toBe(true);
|
||||
expect(Number.isInteger(view.unallocated)).toBe(true);
|
||||
expect(view.unallocated).toBe(10_000 - 3333);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test";
|
||||
import { foldBalances, figuresFor } from "./fold.ts";
|
||||
import type { Dataset, Post, Purchase } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
function post(id: string, fixed: number, archived = false): Post {
|
||||
return {
|
||||
id,
|
||||
@@ -28,6 +35,7 @@ function spend(id: string, postId: string, amount: number, date: string): Purcha
|
||||
function dataset(posts: Post[], purchases: Purchase[]): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: [],
|
||||
posts,
|
||||
months: [
|
||||
|
||||
+3
-1
@@ -1,4 +1,5 @@
|
||||
import { allocationFor } from "./allocation.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import { chargesForMonth } from "./charges.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import { compareMonths, monthRange } from "./months.ts";
|
||||
@@ -36,6 +37,7 @@ export type Fold = Map<MonthId, Map<PostId, PostMonthFigures>>;
|
||||
*/
|
||||
export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold {
|
||||
const { foldStartMonth, baseCurrency } = dataset.settings;
|
||||
const baseDigits = digitsFor(dataset.currencies, baseCurrency);
|
||||
const fold: Fold = new Map();
|
||||
|
||||
if (compareMonths(upToMonth, foldStartMonth) < 0) return fold;
|
||||
@@ -50,7 +52,7 @@ export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold {
|
||||
const carriedIn = carried.get(post.id) ?? 0;
|
||||
const allocation = allocationFor(dataset, post.id, monthId);
|
||||
const spent = charges.get(post.id) ?? 0;
|
||||
const remaining = roundMoney(carriedIn + allocation - spent, baseCurrency);
|
||||
const remaining = roundMoney(carriedIn + allocation - spent, baseDigits);
|
||||
|
||||
monthFigures.set(post.id, {
|
||||
carriedIn,
|
||||
|
||||
+14
-7
@@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test";
|
||||
import { toBase, fromBase, MissingRateError } from "./fx.ts";
|
||||
import type { FxRate } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
const rates: FxRate[] = [
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
|
||||
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
|
||||
@@ -9,23 +16,23 @@ const rates: FxRate[] = [
|
||||
|
||||
describe("toBase", () => {
|
||||
test("passes base-currency values through untouched", () => {
|
||||
expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates)).toBe(100);
|
||||
expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates, TEST_CURRENCIES)).toBe(100);
|
||||
});
|
||||
|
||||
test("converts using baseUnitsPerOne", () => {
|
||||
expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates)).toBe(74.6);
|
||||
expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates, TEST_CURRENCIES)).toBe(74.6);
|
||||
});
|
||||
|
||||
test("rounds the conversion result", () => {
|
||||
expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates)).toBe(22.98);
|
||||
expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates, TEST_CURRENCIES)).toBe(22.98);
|
||||
});
|
||||
|
||||
test("throws MissingRateError naming the currency", () => {
|
||||
expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [])).toThrow(
|
||||
expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES)).toThrow(
|
||||
MissingRateError,
|
||||
);
|
||||
try {
|
||||
toBase({ amount: 1, currency: "USD" }, "DKK", []);
|
||||
toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES);
|
||||
} catch (error) {
|
||||
expect((error as MissingRateError).currency).toBe("USD");
|
||||
}
|
||||
@@ -34,10 +41,10 @@ describe("toBase", () => {
|
||||
|
||||
describe("fromBase", () => {
|
||||
test("passes base through untouched", () => {
|
||||
expect(fromBase(100, "DKK", "DKK", rates)).toBe(100);
|
||||
expect(fromBase(100, "DKK", "DKK", rates, TEST_CURRENCIES)).toBe(100);
|
||||
});
|
||||
|
||||
test("inverts the conversion", () => {
|
||||
expect(fromBase(74.6, "EUR", "DKK", rates)).toBe(10);
|
||||
expect(fromBase(74.6, "EUR", "DKK", rates, TEST_CURRENCIES)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-5
@@ -1,5 +1,6 @@
|
||||
import { roundMoney } from "./money.ts";
|
||||
import type { Currency, FxRate, Money } from "./types.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import type { Currency, CurrencyDef, FxRate, Money } from "./types.ts";
|
||||
|
||||
export class MissingRateError extends Error {
|
||||
constructor(public readonly currency: Currency) {
|
||||
@@ -19,11 +20,13 @@ export function toBase(
|
||||
value: Money,
|
||||
baseCurrency: Currency,
|
||||
rates: FxRate[],
|
||||
currencies: readonly CurrencyDef[],
|
||||
): number {
|
||||
const digits = digitsFor(currencies, baseCurrency);
|
||||
if (value.currency === baseCurrency) {
|
||||
return roundMoney(value.amount, baseCurrency);
|
||||
return roundMoney(value.amount, digits);
|
||||
}
|
||||
return roundMoney(value.amount * rateFor(value.currency, rates), baseCurrency);
|
||||
return roundMoney(value.amount * rateFor(value.currency, rates), digits);
|
||||
}
|
||||
|
||||
/** Converts a base-currency amount into `target`, rounded. */
|
||||
@@ -32,7 +35,9 @@ export function fromBase(
|
||||
target: Currency,
|
||||
baseCurrency: Currency,
|
||||
rates: FxRate[],
|
||||
currencies: readonly CurrencyDef[],
|
||||
): number {
|
||||
if (target === baseCurrency) return roundMoney(amount, target);
|
||||
return roundMoney(amount / rateFor(target, rates), target);
|
||||
const digits = digitsFor(currencies, target);
|
||||
if (target === baseCurrency) return roundMoney(amount, digits);
|
||||
return roundMoney(amount / rateFor(target, rates), digits);
|
||||
}
|
||||
|
||||
+17
-17
@@ -7,62 +7,62 @@ import {
|
||||
|
||||
describe("roundMoney", () => {
|
||||
test("rounds to 2 decimal places", () => {
|
||||
expect(roundMoney(1.234)).toBe(1.23);
|
||||
expect(roundMoney(1.235)).toBe(1.24);
|
||||
expect(roundMoney(0.1 + 0.2)).toBe(0.3);
|
||||
expect(roundMoney(1.234, 2)).toBe(1.23);
|
||||
expect(roundMoney(1.235, 2)).toBe(1.24);
|
||||
expect(roundMoney(0.1 + 0.2, 2)).toBe(0.3);
|
||||
});
|
||||
|
||||
test("handles negatives symmetrically", () => {
|
||||
expect(roundMoney(-1.235)).toBe(-1.24);
|
||||
expect(roundMoney(-1.235, 2)).toBe(-1.24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeByWeight", () => {
|
||||
test("splits proportionally", () => {
|
||||
expect(distributeByWeight(200, [60, 40], 0)).toEqual([120, 80]);
|
||||
expect(distributeByWeight(200, [60, 40], 0, 2)).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
test("thirds sum exactly to the total", () => {
|
||||
const parts = distributeByWeight(100, [1, 1, 1], 2);
|
||||
const parts = distributeByWeight(100, [1, 1, 1], 2, 2);
|
||||
expect(parts).toEqual([33.33, 33.33, 33.34]);
|
||||
expect(roundMoney(parts[0]! + parts[1]! + parts[2]!)).toBe(100);
|
||||
expect(roundMoney(parts[0]! + parts[1]! + parts[2]!, 2)).toBe(100);
|
||||
});
|
||||
|
||||
test("the remainder index absorbs the rounding drift", () => {
|
||||
const parts = distributeByWeight(100, [1, 1, 1], 0);
|
||||
const parts = distributeByWeight(100, [1, 1, 1], 0, 2);
|
||||
expect(parts).toEqual([33.34, 33.33, 33.33]);
|
||||
});
|
||||
|
||||
test("a single split takes the whole total", () => {
|
||||
expect(distributeByWeight(99.99, [100], 0)).toEqual([99.99]);
|
||||
expect(distributeByWeight(99.99, [100], 0, 2)).toEqual([99.99]);
|
||||
});
|
||||
|
||||
test("weights that do not sum to 100 are still proportional", () => {
|
||||
expect(distributeByWeight(100, [1, 3], 1)).toEqual([25, 75]);
|
||||
expect(distributeByWeight(100, [1, 3], 1, 2)).toEqual([25, 75]);
|
||||
});
|
||||
|
||||
test("all-zero weights put everything on the remainder rather than throwing", () => {
|
||||
// Reachable from a zero-value purchase; must not divide by zero.
|
||||
expect(distributeByWeight(100, [0, 0], 1)).toEqual([0, 100]);
|
||||
expect(distributeByWeight(0, [0, 0], 0)).toEqual([0, 0]);
|
||||
expect(distributeByWeight(100, [0, 0], 1, 2)).toEqual([0, 100]);
|
||||
expect(distributeByWeight(0, [0, 0], 0, 2)).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeByAmount", () => {
|
||||
test("uses the given amounts as-is", () => {
|
||||
expect(distributeByAmount(200, [120, 80], 1)).toEqual([120, 80]);
|
||||
expect(distributeByAmount(200, [120, 80], 1, 2)).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
test("the remainder index absorbs a shortfall", () => {
|
||||
expect(distributeByAmount(200, [120, 50], 1)).toEqual([120, 80]);
|
||||
expect(distributeByAmount(200, [120, 50], 1, 2)).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
test("the remainder index absorbs an excess, even going negative", () => {
|
||||
expect(distributeByAmount(100, [120, 50], 1)).toEqual([120, -20]);
|
||||
expect(distributeByAmount(100, [120, 50], 1, 2)).toEqual([120, -20]);
|
||||
});
|
||||
|
||||
test("parts always sum exactly to the total", () => {
|
||||
const parts = distributeByAmount(10, [3.33, 3.33, 0], 2);
|
||||
expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(10);
|
||||
const parts = distributeByAmount(10, [3.33, 3.33, 0], 2, 2);
|
||||
expect(roundMoney(parts.reduce((a, b) => a + b, 0), 2)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-14
@@ -1,13 +1,21 @@
|
||||
import { CURRENCY_DIGITS, type Currency } from "./types.ts";
|
||||
/**
|
||||
* Money is a float. Two rules make that safe, and both are about WHERE
|
||||
* rounding happens rather than how.
|
||||
*
|
||||
* Digits are passed in, never looked up from a table here. They live in the
|
||||
* dataset because the owner can define currencies, so every call site has to
|
||||
* resolve them with `digitsFor` — see src/domain/currencies.ts. That is
|
||||
* deliberate friction: a default of 2 would silently mis-round every
|
||||
* zero-decimal currency and no existing test would notice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rounds a float to its currency's minor unit. `toFixed` rounds the double's
|
||||
* actual decimal value, which is the most predictable option available for
|
||||
* binary floats. Call this after every division and FX conversion, and before
|
||||
* Rounds to a currency's minor unit. `toFixed` rounds the double's actual
|
||||
* decimal value, which is the most predictable option available for binary
|
||||
* floats. Call this after every division and FX conversion, and before
|
||||
* persisting.
|
||||
*/
|
||||
export function roundMoney(amount: number, currency: Currency = "DKK"): number {
|
||||
const digits = CURRENCY_DIGITS[currency];
|
||||
export function roundMoney(amount: number, digits: number): number {
|
||||
return Number(amount.toFixed(digits));
|
||||
}
|
||||
|
||||
@@ -15,7 +23,7 @@ function withRemainder(
|
||||
total: number,
|
||||
parts: number[],
|
||||
remainderIndex: number,
|
||||
currency: Currency,
|
||||
digits: number,
|
||||
): number[] {
|
||||
const result = [...parts];
|
||||
let othersSum = 0;
|
||||
@@ -24,7 +32,7 @@ function withRemainder(
|
||||
}
|
||||
// Never round this independently — subtraction is what makes the parts sum
|
||||
// exactly to the total.
|
||||
result[remainderIndex] = roundMoney(total - othersSum, currency);
|
||||
result[remainderIndex] = roundMoney(total - othersSum, digits);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -33,7 +41,7 @@ export function distributeByWeight(
|
||||
total: number,
|
||||
weights: number[],
|
||||
remainderIndex: number,
|
||||
currency: Currency = "DKK",
|
||||
digits: number,
|
||||
): number[] {
|
||||
const weightSum = weights.reduce((a, b) => a + b, 0);
|
||||
// All-zero weights would divide by zero. A zero-total purchase is legal (and
|
||||
@@ -42,9 +50,9 @@ export function distributeByWeight(
|
||||
const parts = weights.map((w, i) =>
|
||||
i === remainderIndex || weightSum === 0
|
||||
? 0
|
||||
: roundMoney((total * w) / weightSum, currency),
|
||||
: roundMoney((total * w) / weightSum, digits),
|
||||
);
|
||||
return withRemainder(total, parts, remainderIndex, currency);
|
||||
return withRemainder(total, parts, remainderIndex, digits);
|
||||
}
|
||||
|
||||
/** Uses `amounts` as-is; `remainderIndex` absorbs any shortfall or excess. */
|
||||
@@ -52,10 +60,10 @@ export function distributeByAmount(
|
||||
total: number,
|
||||
amounts: number[],
|
||||
remainderIndex: number,
|
||||
currency: Currency = "DKK",
|
||||
digits: number,
|
||||
): number[] {
|
||||
const parts = amounts.map((a, i) =>
|
||||
i === remainderIndex ? 0 : roundMoney(a, currency),
|
||||
i === remainderIndex ? 0 : roundMoney(a, digits),
|
||||
);
|
||||
return withRemainder(total, parts, remainderIndex, currency);
|
||||
return withRemainder(total, parts, remainderIndex, digits);
|
||||
}
|
||||
|
||||
+19
-31
@@ -1,64 +1,52 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { equalSlices, sliceTotal, slicesBalance } from "./plans.ts";
|
||||
import { CURRENCY_DIGITS } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Every currency this app supports today happens to have 2 decimal places, so
|
||||
* code that hardcodes 2 is indistinguishable from code that reads
|
||||
* CURRENCY_DIGITS. This forces the table for the duration of one test, which
|
||||
* is the only way to prove the table is actually consulted without making the
|
||||
* product decision to add a non-2dp currency.
|
||||
* Digits are passed in rather than looked up from a table, so a currency
|
||||
* with a different minor unit is expressed directly instead of by
|
||||
* monkey-patching a constant, which is what this file used to have to do.
|
||||
*/
|
||||
function withDigits(currency: "DKK" | "USD" | "EUR", digits: number, body: () => void) {
|
||||
const original = CURRENCY_DIGITS[currency];
|
||||
CURRENCY_DIGITS[currency] = digits;
|
||||
try {
|
||||
body();
|
||||
} finally {
|
||||
CURRENCY_DIGITS[currency] = original;
|
||||
}
|
||||
}
|
||||
const DKK = 2;
|
||||
|
||||
test("equal slices divide the total across consecutive months", () => {
|
||||
const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6);
|
||||
const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6, DKK);
|
||||
expect(slices).toHaveLength(6);
|
||||
expect(slices[0]).toEqual({ month: "2026-10", amount: { amount: 500, currency: "DKK" } });
|
||||
expect(slices[5]!.month).toBe("2027-03");
|
||||
});
|
||||
|
||||
test("uneven totals still sum exactly, the last slice absorbing the drift", () => {
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3);
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3, DKK);
|
||||
expect(slices.map((s) => s.amount.amount)).toEqual([33.33, 33.33, 33.34]);
|
||||
expect(sliceTotal(slices)).toBe(100);
|
||||
expect(sliceTotal(slices, DKK)).toBe(100);
|
||||
});
|
||||
|
||||
test("a single slice takes the whole total", () => {
|
||||
const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1);
|
||||
const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1, DKK);
|
||||
expect(slices).toEqual([
|
||||
{ month: "2026-01", amount: { amount: 99.99, currency: "DKK" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a count below one is rejected", () => {
|
||||
expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0)).toThrow(
|
||||
expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0, DKK)).toThrow(
|
||||
/at least one month/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("slicesBalance reports the shortfall the editor must show", () => {
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2);
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(0);
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2, DKK);
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices, DKK)).toBe(0);
|
||||
|
||||
slices[0]!.amount.amount = 30;
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(20);
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices, DKK)).toBe(20);
|
||||
});
|
||||
|
||||
test("sliceTotal rounds to the slices' own currency, not always to 2 places", () => {
|
||||
withDigits("EUR", 0, () => {
|
||||
const slices = [
|
||||
{ month: "2026-01" as const, amount: { amount: 10.4, currency: "EUR" as const } },
|
||||
{ month: "2026-02" as const, amount: { amount: 10.4, currency: "EUR" as const } },
|
||||
];
|
||||
expect(sliceTotal(slices)).toBe(21);
|
||||
});
|
||||
test("sliceTotal rounds to the digits it is given, not always to 2 places", () => {
|
||||
const slices = [
|
||||
{ month: "2026-01", amount: { amount: 10.4, currency: "EUR" } },
|
||||
{ month: "2026-02", amount: { amount: 10.4, currency: "EUR" } },
|
||||
];
|
||||
expect(sliceTotal(slices, 0)).toBe(21);
|
||||
expect(sliceTotal(slices, 2)).toBe(20.8);
|
||||
});
|
||||
|
||||
+10
-10
@@ -7,17 +7,13 @@ export function equalSlices(
|
||||
total: Money,
|
||||
startMonth: MonthId,
|
||||
count: number,
|
||||
digits: number,
|
||||
): ScheduleSlice[] {
|
||||
if (!Number.isInteger(count) || count < 1) {
|
||||
throw new Error("A finance plan needs at least one month");
|
||||
}
|
||||
const weights = new Array(count).fill(1);
|
||||
const amounts = distributeByWeight(
|
||||
total.amount,
|
||||
weights,
|
||||
count - 1,
|
||||
total.currency,
|
||||
);
|
||||
const amounts = distributeByWeight(total.amount, weights, count - 1, digits);
|
||||
return amounts.map((amount, i) => ({
|
||||
month: addMonths(startMonth, i),
|
||||
amount: { amount, currency: total.currency },
|
||||
@@ -29,12 +25,16 @@ export function equalSlices(
|
||||
* names the currency to round in. Rounding to a hardcoded 2 places would be
|
||||
* wrong for any currency whose minor unit is not hundredths.
|
||||
*/
|
||||
export function sliceTotal(slices: ScheduleSlice[]): number {
|
||||
export function sliceTotal(slices: ScheduleSlice[], digits: number): number {
|
||||
const sum = slices.reduce((total, s) => total + s.amount.amount, 0);
|
||||
return roundMoney(sum, slices[0]?.amount.currency);
|
||||
return roundMoney(sum, digits);
|
||||
}
|
||||
|
||||
/** total - sum(slices). Positive means the plan is short of the total. */
|
||||
export function slicesBalance(total: Money, slices: ScheduleSlice[]): number {
|
||||
return roundMoney(total.amount - sliceTotal(slices), total.currency);
|
||||
export function slicesBalance(
|
||||
total: Money,
|
||||
slices: ScheduleSlice[],
|
||||
digits: number,
|
||||
): number {
|
||||
return roundMoney(total.amount - sliceTotal(slices, digits), digits);
|
||||
}
|
||||
|
||||
+6
-3
@@ -1,6 +1,7 @@
|
||||
import type { Dataset, FxRate, MonthId, Post } from "./types.ts";
|
||||
import { SEED_CURRENCIES } from "./types.ts";
|
||||
import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts";
|
||||
|
||||
export const SCHEMA_VERSION = 2;
|
||||
export const SCHEMA_VERSION = 3;
|
||||
|
||||
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
|
||||
|
||||
@@ -17,6 +18,7 @@ export function newId(): string {
|
||||
export function createSeedDataset(
|
||||
startMonth: MonthId,
|
||||
fxRates: readonly FxRate[] = [],
|
||||
currencies: readonly CurrencyDef[] = SEED_CURRENCIES,
|
||||
): Dataset {
|
||||
const posts: Post[] = SEED_POST_NAMES.map((name, order) => ({
|
||||
id: newId(),
|
||||
@@ -36,7 +38,8 @@ export function createSeedDataset(
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
},
|
||||
// Copied, so a caller passing shared constants cannot have them edited
|
||||
// out from under it the first time the user changes a rate.
|
||||
// out from under it the first time the user changes a rate or a currency.
|
||||
currencies: currencies.map((currency) => ({ ...currency })),
|
||||
fxRates: fxRates.map((rate) => ({ ...rate })),
|
||||
posts,
|
||||
months: [
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { CURRENCIES, CURRENCY_DIGITS } from "./types.ts";
|
||||
import { DEFAULT_CURRENCY_DIGITS, SEED_CURRENCIES } from "./types.ts";
|
||||
|
||||
test("supported currencies are exactly DKK, USD, EUR", () => {
|
||||
expect(CURRENCIES).toEqual(["DKK", "USD", "EUR"]);
|
||||
/**
|
||||
* This file used to assert "the supported currencies are exactly DKK, USD,
|
||||
* EUR". That is no longer a property of the code: the owner defines
|
||||
* 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("every supported currency has 2 decimal digits", () => {
|
||||
for (const c of CURRENCIES) {
|
||||
expect(CURRENCY_DIGITS[c]).toBe(2);
|
||||
test("each seeded currency carries its decimals and a symbol to type", () => {
|
||||
for (const currency of SEED_CURRENCIES) {
|
||||
expect(currency.digits).toBe(2);
|
||||
expect(currency.symbol).toBeTruthy();
|
||||
expect(currency.name).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("the fallback for an undefined currency is two places", () => {
|
||||
expect(DEFAULT_CURRENCY_DIGITS).toBe(2);
|
||||
});
|
||||
|
||||
+39
-7
@@ -1,11 +1,37 @@
|
||||
export const CURRENCIES = ["DKK", "USD", "EUR"] as const;
|
||||
export type Currency = (typeof CURRENCIES)[number];
|
||||
/**
|
||||
* A currency CODE, uppercase, normally ISO 4217 ("DKK", "JPY"). Open rather
|
||||
* than a closed union because the owner can define their own currencies:
|
||||
* a union would make that a code change.
|
||||
*
|
||||
* The safety a union gave up is replaced by validation at the boundaries —
|
||||
* `parseDatasetJson` rejects a code no currency defines, and the UI only ever
|
||||
* offers codes from the dataset's own table.
|
||||
*/
|
||||
export type Currency = string;
|
||||
|
||||
export const CURRENCY_DIGITS: Record<Currency, number> = {
|
||||
DKK: 2,
|
||||
USD: 2,
|
||||
EUR: 2,
|
||||
};
|
||||
/** What the owner has told the app about one currency. */
|
||||
export interface CurrencyDef {
|
||||
code: Currency;
|
||||
/** Decimal places in its minor unit: 2 for DKK, 0 for JPY, 3 for KWD. */
|
||||
digits: number;
|
||||
/** "kr", "$", "€". Optional — a currency can be typed by code alone. */
|
||||
symbol?: string;
|
||||
/** "Danish krone". Shown in Settings; never used for identity. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The currencies a brand-new dataset starts with. NOT the set of valid
|
||||
* currencies — that lives in the dataset, because the owner can add more.
|
||||
*/
|
||||
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" },
|
||||
];
|
||||
|
||||
/** Used when a currency has no definition. Two places is right almost always. */
|
||||
export const DEFAULT_CURRENCY_DIGITS = 2;
|
||||
|
||||
/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */
|
||||
export interface Money {
|
||||
@@ -106,6 +132,12 @@ export interface Settings {
|
||||
|
||||
export interface Dataset {
|
||||
settings: Settings;
|
||||
/**
|
||||
* Every currency this dataset knows, including the base. The owner can add
|
||||
* to it, so it — not a compile-time union — is the authority on what codes
|
||||
* are valid, what their symbols are, and how many decimals they round to.
|
||||
*/
|
||||
currencies: CurrencyDef[];
|
||||
fxRates: FxRate[];
|
||||
posts: Post[];
|
||||
months: Month[];
|
||||
|
||||
@@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test";
|
||||
import { monthView, yearView, summaryView, datasetMonthSpan } from "./views.ts";
|
||||
import type { Dataset, Post, Purchase } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
function post(id: string, order: number, percent: number, archived = false): Post {
|
||||
return {
|
||||
id,
|
||||
@@ -27,6 +34,7 @@ function spend(id: string, postId: string, amount: number, date: string): Purcha
|
||||
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: [],
|
||||
posts: [post("food", 0, 20), post("games", 1, 10)],
|
||||
months: [
|
||||
|
||||
+15
-11
@@ -1,4 +1,5 @@
|
||||
import { incomeFor, isOverridden } from "./allocation.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts";
|
||||
@@ -44,13 +45,14 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
|
||||
}
|
||||
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const baseDigits = digitsFor(dataset.currencies, base);
|
||||
const totalAllocation = roundMoney(
|
||||
rows.reduce((sum, r) => sum + r.figures.allocation, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
);
|
||||
const totalCharges = roundMoney(
|
||||
rows.reduce((sum, r) => sum + r.figures.charges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -58,7 +60,7 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
|
||||
income,
|
||||
totalAllocation,
|
||||
totalCharges,
|
||||
unallocated: roundMoney(income - totalAllocation, base),
|
||||
unallocated: roundMoney(income - totalAllocation, baseDigits),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
@@ -86,6 +88,7 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
|
||||
const months = monthsOfYear(year);
|
||||
const fold = foldBalances(dataset, months[11]!);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const baseDigits = digitsFor(dataset.currencies, base);
|
||||
|
||||
const incomeByMonth = months.map((m) => incomeFor(dataset, m));
|
||||
|
||||
@@ -96,11 +99,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
|
||||
byMonth,
|
||||
totalAllocation: roundMoney(
|
||||
byMonth.reduce((sum, f) => sum + f.allocation, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
totalCharges: roundMoney(
|
||||
byMonth.reduce((sum, f) => sum + f.charges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
closingBalance: byMonth[11]!.remaining,
|
||||
};
|
||||
@@ -112,11 +115,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
|
||||
incomeByMonth,
|
||||
totalIncome: roundMoney(
|
||||
incomeByMonth.reduce((a, b) => a + b, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
totalCharges: roundMoney(
|
||||
rows.reduce((sum, r) => sum + r.totalCharges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
rows,
|
||||
};
|
||||
@@ -138,6 +141,7 @@ export function summaryView(
|
||||
): SummaryViewModel {
|
||||
const months = monthRange(from, to);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const baseDigits = digitsFor(dataset.currencies, base);
|
||||
const fold: Fold =
|
||||
months.length > 0 ? foldBalances(dataset, months[months.length - 1]!) : new Map();
|
||||
|
||||
@@ -145,7 +149,7 @@ export function summaryView(
|
||||
post,
|
||||
charges: roundMoney(
|
||||
months.reduce((sum, m) => sum + figuresFor(fold, post.id, m).charges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -156,7 +160,7 @@ export function summaryView(
|
||||
(sum, p) => sum + figuresFor(fold, p.id, monthId).charges,
|
||||
0,
|
||||
),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -167,11 +171,11 @@ export function summaryView(
|
||||
byMonth,
|
||||
totalCharges: roundMoney(
|
||||
byMonth.reduce((sum, m) => sum + m.charges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
totalIncome: roundMoney(
|
||||
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
+82
-11
@@ -1,4 +1,4 @@
|
||||
import { CURRENCIES, type Currency, type Dataset, type MonthId } from "../domain/types.ts";
|
||||
import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts";
|
||||
import { migrate } from "../store/migrations.ts";
|
||||
|
||||
export class ImportValidationError extends Error {
|
||||
@@ -36,11 +36,80 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;
|
||||
*/
|
||||
const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/;
|
||||
|
||||
function requireCurrency(value: unknown, where: string): Currency {
|
||||
if (!CURRENCIES.includes(value as Currency)) {
|
||||
throw new ImportValidationError(`Unsupported currency ${String(value)} in ${where}`);
|
||||
/**
|
||||
* `Currency` is an open string now, so this — not the type system — is what
|
||||
* stops a code the dataset never defined reaching the fold, where it would
|
||||
* throw MissingRateError or round at a guessed number of decimals.
|
||||
*/
|
||||
function requireCurrency(
|
||||
value: unknown,
|
||||
where: string,
|
||||
defined: ReadonlySet<Currency>,
|
||||
): Currency {
|
||||
if (typeof value !== "string" || !defined.has(value)) {
|
||||
throw new ImportValidationError(
|
||||
`Unsupported currency ${String(value)} in ${where}. Define it under Settings first.`,
|
||||
);
|
||||
}
|
||||
return value as Currency;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Codes are identity: they key the FX table and every stored Money. */
|
||||
const CURRENCY_CODE = /^[A-Z]{2,8}$/;
|
||||
|
||||
/**
|
||||
* Validates the currency table itself, before anything is checked against it.
|
||||
* A table with a duplicate code would make `digitsFor` resolve arbitrarily,
|
||||
* and a non-integer or absurd `digits` would round money to nonsense.
|
||||
*/
|
||||
function requireCurrencyTable(data: Record<string, unknown>): CurrencyDef[] {
|
||||
const raw = data.currencies;
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new ImportValidationError('Dataset field "currencies" is missing or not an array');
|
||||
}
|
||||
if (raw.length === 0) {
|
||||
throw new ImportValidationError("A dataset must define at least one currency");
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const table: CurrencyDef[] = [];
|
||||
|
||||
for (const entry of raw as Array<Record<string, unknown>>) {
|
||||
const code = entry?.code;
|
||||
if (typeof code !== "string" || !CURRENCY_CODE.test(code)) {
|
||||
throw new ImportValidationError(
|
||||
`Currency code ${JSON.stringify(code)} is not 2-8 uppercase letters`,
|
||||
);
|
||||
}
|
||||
if (seen.has(code)) {
|
||||
throw new ImportValidationError(
|
||||
`Currency "${code}" is defined twice; which decimals apply would be arbitrary`,
|
||||
);
|
||||
}
|
||||
seen.add(code);
|
||||
|
||||
const digits = entry.digits;
|
||||
if (typeof digits !== "number" || !Number.isInteger(digits) || digits < 0 || digits > 4) {
|
||||
throw new ImportValidationError(
|
||||
`Currency "${code}" has invalid decimal places ${String(digits)}; expected an integer 0-4`,
|
||||
);
|
||||
}
|
||||
if (entry.symbol !== undefined && typeof entry.symbol !== "string") {
|
||||
throw new ImportValidationError(`Currency "${code}" has a non-string symbol`);
|
||||
}
|
||||
if (entry.name !== undefined && typeof entry.name !== "string") {
|
||||
throw new ImportValidationError(`Currency "${code}" has a non-string name`);
|
||||
}
|
||||
|
||||
table.push({
|
||||
code,
|
||||
digits,
|
||||
...(typeof entry.symbol === "string" ? { symbol: entry.symbol } : {}),
|
||||
...(typeof entry.name === "string" ? { name: entry.name } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
function requireArray(data: Record<string, unknown>, key: string): unknown[] {
|
||||
@@ -75,12 +144,14 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
}
|
||||
|
||||
const data = dataset as unknown as Record<string, unknown>;
|
||||
const currencies = requireCurrencyTable(data);
|
||||
const defined = new Set(currencies.map((currency) => currency.code));
|
||||
requireArray(data, "posts");
|
||||
requireArray(data, "months");
|
||||
requireArray(data, "purchases");
|
||||
requireArray(data, "fxRates");
|
||||
|
||||
requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency");
|
||||
requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency", defined);
|
||||
if (!MONTH_ID.test(dataset.settings.foldStartMonth)) {
|
||||
throw new ImportValidationError(
|
||||
`Invalid foldStartMonth "${dataset.settings.foldStartMonth}"`,
|
||||
@@ -89,7 +160,7 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
|
||||
const postIds = new Set(dataset.posts.map((p) => p.id));
|
||||
for (const post of dataset.posts) {
|
||||
requireCurrency(post.currency, `post "${post.name}"`);
|
||||
requireCurrency(post.currency, `post "${post.name}"`, defined);
|
||||
|
||||
const rules = (post as unknown as Record<string, unknown>).rules;
|
||||
if (!Array.isArray(rules)) {
|
||||
@@ -120,7 +191,7 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
amount?: { currency?: unknown };
|
||||
};
|
||||
if (rule.kind === "fixed") {
|
||||
requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`);
|
||||
requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`, defined);
|
||||
} else if (rule.kind !== "percentOfIncome") {
|
||||
throw new ImportValidationError(
|
||||
`Post "${post.name}" has a rule from "${from}" of unknown kind "${String(rule.kind)}"`,
|
||||
@@ -133,12 +204,12 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
if (!MONTH_ID.test(month.id)) {
|
||||
throw new ImportValidationError(`Invalid month id "${month.id}"`);
|
||||
}
|
||||
requireCurrency(month.income.currency, `month ${month.id} income`);
|
||||
requireCurrency(month.income.currency, `month ${month.id} income`, defined);
|
||||
}
|
||||
|
||||
for (const purchase of dataset.purchases) {
|
||||
const label = `purchase "${purchase.description}"`;
|
||||
requireCurrency(purchase.total.currency, label);
|
||||
requireCurrency(purchase.total.currency, label, defined);
|
||||
|
||||
if (!PURCHASE_DATE.test(purchase.date)) {
|
||||
throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`);
|
||||
@@ -163,7 +234,7 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
if (!MONTH_ID.test(slice.month)) {
|
||||
throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`);
|
||||
}
|
||||
requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`);
|
||||
requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`, defined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-2
@@ -2,6 +2,7 @@ import { zipSync, strToU8 } from "fflate";
|
||||
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts";
|
||||
import { monthRange } from "../domain/months.ts";
|
||||
import { roundMoney } from "../domain/money.ts";
|
||||
import { digitsFor } from "../domain/currencies.ts";
|
||||
import { datasetMonthSpan, monthView, summaryView } from "../domain/views.ts";
|
||||
import type { MonthViewModel } from "../domain/views.ts";
|
||||
import type { PostMonthFigures } from "../domain/fold.ts";
|
||||
@@ -31,7 +32,7 @@ function summarySheet(dataset: Dataset, months: MonthId[], byMonth: FiguresByMon
|
||||
rows.push([
|
||||
text(post.name),
|
||||
...perMonth.map(num),
|
||||
num(roundMoney(perMonth.reduce((a, b) => a + b, 0), base)),
|
||||
num(roundMoney(perMonth.reduce((a, b) => a + b, 0), digitsFor(dataset.currencies, base))),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -82,7 +83,13 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet {
|
||||
for (const purchase of dataset.purchases) {
|
||||
const slice = sliceAmountForMonth(purchase, monthId);
|
||||
if (!slice) continue;
|
||||
const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates);
|
||||
const charges = chargesForPurchaseInMonth(
|
||||
purchase,
|
||||
monthId,
|
||||
base,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
for (const charge of charges) {
|
||||
rows.push([
|
||||
text(monthId),
|
||||
|
||||
+14
-10
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -45,15 +45,15 @@ describe("blank lines", () => {
|
||||
|
||||
describe("bulkTotal", () => {
|
||||
test("sums the filled lines", () => {
|
||||
expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }))).toBe(55.5);
|
||||
expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }), 2)).toBe(55.5);
|
||||
});
|
||||
|
||||
test("is zero when there is nothing filled in", () => {
|
||||
expect(bulkTotal(draft())).toBe(0);
|
||||
expect(bulkTotal(draft(), 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("rounds the sum, so float tails never surface", () => {
|
||||
expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }))).toBe(0.3);
|
||||
expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }), 2)).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -69,10 +69,10 @@ export function filledLines(draft: BulkDraft): BulkLine[] {
|
||||
return draft.lines.filter((line) => !isBlankLine(line));
|
||||
}
|
||||
|
||||
export function bulkTotal(draft: BulkDraft): number {
|
||||
export function bulkTotal(draft: BulkDraft, digits: number): number {
|
||||
return roundMoney(
|
||||
filledLines(draft).reduce((sum, line) => sum + line.amount, 0),
|
||||
draft.currency,
|
||||
digits,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useRef } from "react";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { digitsFor } from "../../domain/currencies.ts";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatMoney } from "../format.ts";
|
||||
@@ -24,6 +26,7 @@ interface Props {
|
||||
* on touch devices too. That is why there is no "Add line" button.
|
||||
*/
|
||||
export function BulkLines({ draft, onChange }: Props) {
|
||||
const digits = digitsFor(useDataset().currencies, draft.currency);
|
||||
const descriptionRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const noteRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
@@ -125,7 +128,7 @@ export function BulkLines({ draft, onChange }: Props) {
|
||||
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{filledCount} line{filledCount === 1 ? "" : "s"} ·{" "}
|
||||
<span className="font-money">{formatMoney(bulkTotal(draft), draft.currency)}</span>
|
||||
<span className="font-money">{formatMoney(bulkTotal(draft, digits), draft.currency)}</span>
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { digitsFor } from "../../domain/currencies.ts";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatAmount } from "../format.ts";
|
||||
@@ -28,7 +30,10 @@ function hasValidDate(draft: PurchaseDraft): boolean {
|
||||
}
|
||||
|
||||
export function PlanEditor({ draft, onChange }: Props) {
|
||||
const balance = planBalance(draft);
|
||||
// The purchase's own currency decides the rounding, not the base currency:
|
||||
// a plan for a JPY purchase must divide into whole yen.
|
||||
const digits = digitsFor(useDataset().currencies, draft.currency);
|
||||
const balance = planBalance(draft, digits);
|
||||
|
||||
if (!draft.plan) {
|
||||
const dateReady = hasValidDate(draft);
|
||||
@@ -41,7 +46,7 @@ export function PlanEditor({ draft, onChange }: Props) {
|
||||
disabled={!dateReady}
|
||||
onClick={() => {
|
||||
if (!dateReady) return;
|
||||
onChange(withPlan(draft, monthOf(draft.date), 3));
|
||||
onChange(withPlan(draft, monthOf(draft.date), 3, digits));
|
||||
}}
|
||||
>
|
||||
Spread this over several months
|
||||
@@ -70,7 +75,7 @@ export function PlanEditor({ draft, onChange }: Props) {
|
||||
onChange={(event) => {
|
||||
const months = Number(event.target.value);
|
||||
if (Number.isInteger(months) && months >= 1) {
|
||||
onChange(withPlan(draft, draft.plan!.startMonth, months));
|
||||
onChange(withPlan(draft, draft.plan!.startMonth, months, digits));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -27,9 +27,10 @@ import {
|
||||
type BulkDraft,
|
||||
} from "../bulkEntry.ts";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { parseMoneyInput } from "../moneyInput.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
|
||||
import { CURRENCIES, type MonthId, type Purchase } from "../../domain/types.ts";
|
||||
import type { MonthId, Purchase } from "../../domain/types.ts";
|
||||
|
||||
interface Props {
|
||||
monthId: MonthId;
|
||||
@@ -49,6 +50,11 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
const [draft, setDraft] = useState<PurchaseDraft>(() =>
|
||||
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
// The amount field keeps its own text so a half-typed "30." or "30 " stays
|
||||
// exactly as typed; `draft.amount` only moves when the text parses.
|
||||
const [amountText, setAmountText] = useState(() =>
|
||||
purchase ? String(purchase.total.amount) : "",
|
||||
);
|
||||
// "many" is only meaningful when adding — editing an existing purchase edits
|
||||
// exactly one, so the toggle is hidden in that case.
|
||||
const [mode, setMode] = useState<"one" | "many">("one");
|
||||
@@ -179,7 +185,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
setBulk((b) => ({ ...b, currency }));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
{dataset.currencies.map(({ code: currency }) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
@@ -247,15 +253,24 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purchase-amount">Total</Label>
|
||||
{/* type="text", not "number": a number input silently discards
|
||||
anything non-numeric, so "30$" would never reach the parser.
|
||||
Entry speed is the point — the currency is set from what was
|
||||
typed rather than from a second control. */}
|
||||
<Input
|
||||
id="purchase-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
className="font-money"
|
||||
value={draft.amount}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, amount: Number(event.target.value) || 0 })
|
||||
}
|
||||
placeholder="30$"
|
||||
value={amountText}
|
||||
onChange={(event) => {
|
||||
const typed = event.target.value;
|
||||
setAmountText(typed);
|
||||
const parsed = parseMoneyInput(typed, dataset.currencies, draft.currency);
|
||||
if (parsed) {
|
||||
setDraft({ ...draft, amount: parsed.amount, currency: parsed.currency });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -268,7 +283,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
setDraft({ ...draft, currency: event.target.value as typeof draft.currency })
|
||||
}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
{dataset.currencies.map(({ code: currency }) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { digitsFor } from "../../domain/currencies.ts";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatAmount } from "../format.ts";
|
||||
@@ -13,7 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SplitEditor({ draft, posts, onChange }: Props) {
|
||||
const balance = splitBalance(draft);
|
||||
const balance = splitBalance(draft, digitsFor(useDataset().currencies, draft.currency));
|
||||
const unit = draft.splitMode === "percent" ? "%" : draft.currency;
|
||||
|
||||
function updateSplit(index: number, changes: Partial<SplitDraft>) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { parseMoneyInput, type CurrencyOption } from "./moneyInput.ts";
|
||||
|
||||
const OPTIONS: CurrencyOption[] = [
|
||||
{ code: "DKK", symbol: "kr" },
|
||||
{ code: "USD", symbol: "$" },
|
||||
{ code: "EUR", symbol: "€" },
|
||||
];
|
||||
const parse = (text: string, fallback = "DKK") =>
|
||||
parseMoneyInput(text, OPTIONS, fallback);
|
||||
|
||||
describe("reading the currency out of what was typed", () => {
|
||||
test("a trailing symbol", () => {
|
||||
expect(parse("30$")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a leading symbol", () => {
|
||||
expect(parse("$30")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a symbol with a space either side", () => {
|
||||
expect(parse("30 $")).toEqual({ amount: 30, currency: "USD" });
|
||||
expect(parse("$ 30")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a three-letter code, in any case", () => {
|
||||
expect(parse("30 usd")).toEqual({ amount: 30, currency: "USD" });
|
||||
expect(parse("USD 30")).toEqual({ amount: 30, currency: "USD" });
|
||||
expect(parse("30EUR")).toEqual({ amount: 30, currency: "EUR" });
|
||||
});
|
||||
|
||||
test("a multi-letter symbol", () => {
|
||||
expect(parse("250 kr")).toEqual({ amount: 250, currency: "DKK" });
|
||||
expect(parse("250kr")).toEqual({ amount: 250, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("no currency at all falls back to the one the field is in", () => {
|
||||
expect(parse("30")).toEqual({ amount: 30, currency: "DKK" });
|
||||
expect(parse("30", "EUR")).toEqual({ amount: 30, currency: "EUR" });
|
||||
});
|
||||
|
||||
test("a code wins over a symbol when both appear", () => {
|
||||
// "$30 USD" is contradictory; the explicit code is the stronger signal.
|
||||
expect(parse("$30 USD")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("an unknown currency is rejected rather than silently ignored", () => {
|
||||
// Silently dropping "GBP" would book a GBP purchase as DKK.
|
||||
expect(parse("30 GBP")).toBeNull();
|
||||
expect(parse("30 £")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading the number out of what was typed", () => {
|
||||
test("plain decimals", () => {
|
||||
expect(parse("30.50")).toEqual({ amount: 30.5, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("a comma decimal separator, as most of Europe writes it", () => {
|
||||
expect(parse("30,50")).toEqual({ amount: 30.5, currency: "DKK" });
|
||||
expect(parse("€25,50")).toEqual({ amount: 25.5, currency: "EUR" });
|
||||
});
|
||||
|
||||
/**
|
||||
* The genuinely ambiguous cases. The rule: when both separators appear, the
|
||||
* LAST one is the decimal point. When only one appears, it is a thousands
|
||||
* separator if exactly three digits follow it, and a decimal point
|
||||
* otherwise. "1,500" is therefore one thousand five hundred, which is what
|
||||
* someone typing a budget figure means.
|
||||
*/
|
||||
test("both separators: the last one is the decimal point", () => {
|
||||
expect(parse("1.234,56")).toEqual({ amount: 1234.56, currency: "DKK" });
|
||||
expect(parse("1,234.56")).toEqual({ amount: 1234.56, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("one separator with three digits after it groups thousands", () => {
|
||||
expect(parse("1,500")).toEqual({ amount: 1500, currency: "DKK" });
|
||||
expect(parse("1.500")).toEqual({ amount: 1500, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("one separator with one or two digits after it is a decimal point", () => {
|
||||
expect(parse("1,5")).toEqual({ amount: 1.5, currency: "DKK" });
|
||||
expect(parse("1.50")).toEqual({ amount: 1.5, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("spaces and non-breaking spaces group thousands", () => {
|
||||
expect(parse("28 000")).toEqual({ amount: 28000, currency: "DKK" });
|
||||
expect(parse("28 000,50")).toEqual({ amount: 28000.5, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("a negative amount is legal — a refund is a normal line", () => {
|
||||
expect(parse("-500")).toEqual({ amount: -500, currency: "DKK" });
|
||||
expect(parse("-30$")).toEqual({ amount: -30, currency: "USD" });
|
||||
expect(parse("$-30")).toEqual({ amount: -30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a leading plus is accepted and means nothing", () => {
|
||||
expect(parse("+30")).toEqual({ amount: 30, currency: "DKK" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("what is not a money value", () => {
|
||||
test.each([
|
||||
["empty", ""],
|
||||
["only spaces", " "],
|
||||
["only a currency", "$"],
|
||||
["only a code", "USD"],
|
||||
["letters", "abc"],
|
||||
["two numbers", "30 40"],
|
||||
["a bare separator", ","],
|
||||
["a lone minus", "-"],
|
||||
])("%s is not a money value", (_label, text) => {
|
||||
expect(parse(text)).toBeNull();
|
||||
});
|
||||
|
||||
test("a half-typed decimal is not yet a value, and must not become one", () => {
|
||||
// Committing "30." as 30 would fight the user mid-keystroke.
|
||||
expect(parse("30.")).toBeNull();
|
||||
expect(parse("30,")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("symbols that collide", () => {
|
||||
const withCollision: CurrencyOption[] = [
|
||||
{ code: "USD", symbol: "$" },
|
||||
{ code: "CAD", symbol: "$" },
|
||||
];
|
||||
|
||||
test("the first currency defined with that symbol wins, and the code disambiguates", () => {
|
||||
expect(parseMoneyInput("30$", withCollision, "USD")).toEqual({
|
||||
amount: 30,
|
||||
currency: "USD",
|
||||
});
|
||||
expect(parseMoneyInput("30 CAD", withCollision, "USD")).toEqual({
|
||||
amount: 30,
|
||||
currency: "CAD",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Currency } from "../domain/types.ts";
|
||||
|
||||
export interface CurrencyOption {
|
||||
code: Currency;
|
||||
/** "$", "kr", "€". Optional: a currency can be entered by code alone. */
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export interface ParsedMoney {
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an amount AND its currency out of one field, so "30$" means thirty
|
||||
* dollars without touching a separate dropdown. Entry speed is a stated
|
||||
* product feature; a second control per amount is the opposite of it.
|
||||
*
|
||||
* Returns null for anything that is not yet a money value, including a
|
||||
* half-typed "30." — committing that as 30 would fight the user mid-keystroke.
|
||||
* An unrecognised currency also returns null rather than falling back, because
|
||||
* silently reading "30 GBP" as 30 DKK books money at the wrong currency.
|
||||
*/
|
||||
export function parseMoneyInput(
|
||||
text: string,
|
||||
options: readonly CurrencyOption[],
|
||||
fallback: Currency,
|
||||
): ParsedMoney | null {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "") return null;
|
||||
|
||||
const currency = detectCurrency(trimmed, options);
|
||||
if (currency === "unknown") return null;
|
||||
|
||||
const amount = parseAmount(stripCurrency(trimmed, options));
|
||||
if (amount === null) return null;
|
||||
|
||||
return { amount, currency: currency ?? fallback };
|
||||
}
|
||||
|
||||
/**
|
||||
* A three-letter code beats a symbol: "$30 USD" is contradictory, and the code
|
||||
* is the more deliberate signal. Returns `null` when no currency was written
|
||||
* at all, and "unknown" when something currency-shaped was written that this
|
||||
* dataset has no currency for.
|
||||
*/
|
||||
function detectCurrency(
|
||||
text: string,
|
||||
options: readonly CurrencyOption[],
|
||||
): Currency | null | "unknown" {
|
||||
const codes = new Map(options.map((o) => [o.code.toUpperCase(), o.code]));
|
||||
|
||||
// A bare three-letter word, not part of a longer word.
|
||||
const words = text.toUpperCase().match(/[A-Z]{2,}/g) ?? [];
|
||||
for (const word of words) {
|
||||
const matched = codes.get(word);
|
||||
if (matched) return matched;
|
||||
}
|
||||
|
||||
// Longest symbol first, so "kr." cannot be shadowed by "kr".
|
||||
const symbols = options
|
||||
.filter((o) => o.symbol)
|
||||
.sort((a, b) => b.symbol!.length - a.symbol!.length);
|
||||
for (const option of symbols) {
|
||||
if (text.includes(option.symbol!)) return option.code;
|
||||
}
|
||||
|
||||
// Anything left that is neither digits, separators nor a sign is a currency
|
||||
// this dataset does not define.
|
||||
if (/[^\d\s.,+-]/.test(text)) return "unknown";
|
||||
|
||||
// A word that is not a known code is unknown too ("30 GBP").
|
||||
if (words.length > 0) return "unknown";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripCurrency(text: string, options: readonly CurrencyOption[]): string {
|
||||
let out = text;
|
||||
for (const option of options) {
|
||||
if (option.symbol) out = out.split(option.symbol).join(" ");
|
||||
out = out.replace(new RegExp(option.code, "gi"), " ");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separator handling, which is where this earns its tests. When BOTH "." and
|
||||
* "," appear, the last one is the decimal point — that covers "1.234,56" and
|
||||
* "1,234.56" without knowing the user's locale. When only one appears, 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.
|
||||
*/
|
||||
function parseAmount(text: string): number | null {
|
||||
// Normalise every kind of space to a plain one before splitting.
|
||||
const spaced = text.replace(/\s+/g, " ").trim();
|
||||
if (spaced === "") return null;
|
||||
|
||||
const sign = spaced.startsWith("-") ? -1 : 1;
|
||||
const unsigned = spaced.replace(/^[+-]\s*/, "");
|
||||
|
||||
// A space only ever GROUPS thousands, so every group after the first must be
|
||||
// exactly three digits. Without this, "30 40" silently becomes 3040 — two
|
||||
// amounts typed by mistake, committed as one much larger one.
|
||||
const groups = unsigned.split(" ").filter((group) => group !== "");
|
||||
if (groups.length === 0) return null;
|
||||
if (groups.length > 1) {
|
||||
const allGrouped = groups
|
||||
.slice(1)
|
||||
.every((group) => /^\d{3}([.,]\d+)?$/.test(group));
|
||||
if (!allGrouped) return null;
|
||||
}
|
||||
|
||||
const digits = groups.join("");
|
||||
if (!/^[\d.,]+$/.test(digits)) return null;
|
||||
if (/^[.,]+$/.test(digits)) return null;
|
||||
// A trailing separator means the number is still being typed.
|
||||
if (/[.,]$/.test(digits)) return null;
|
||||
|
||||
const lastDot = digits.lastIndexOf(".");
|
||||
const lastComma = digits.lastIndexOf(",");
|
||||
let decimalAt = -1;
|
||||
|
||||
if (lastDot >= 0 && lastComma >= 0) {
|
||||
decimalAt = Math.max(lastDot, lastComma);
|
||||
} else if (lastDot >= 0 || lastComma >= 0) {
|
||||
const only = Math.max(lastDot, lastComma);
|
||||
if (digits.length - only - 1 !== 3) decimalAt = only;
|
||||
}
|
||||
|
||||
const whole = (decimalAt === -1 ? digits : digits.slice(0, decimalAt)).replace(
|
||||
/[.,]/g,
|
||||
"",
|
||||
);
|
||||
const fraction = decimalAt === -1 ? "" : digits.slice(decimalAt + 1);
|
||||
if (/[.,]/.test(fraction)) return null;
|
||||
|
||||
const value = Number(
|
||||
`${whole === "" ? "0" : whole}.${fraction === "" ? "0" : fraction}`,
|
||||
);
|
||||
return Number.isFinite(value) ? sign * value : null;
|
||||
}
|
||||
|
||||
+14
-13
@@ -30,7 +30,7 @@ describe("emptyDraft", () => {
|
||||
|
||||
describe("splitBalance", () => {
|
||||
test("is zero when percentages total 100", () => {
|
||||
expect(splitBalance(draft())).toBe(0);
|
||||
expect(splitBalance(draft(), 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("reports the missing percentage", () => {
|
||||
@@ -40,7 +40,7 @@ describe("splitBalance", () => {
|
||||
{ postId: "events", value: 30, absorbsRemainder: false },
|
||||
],
|
||||
});
|
||||
expect(splitBalance(d)).toBe(10);
|
||||
expect(splitBalance(d, 2)).toBe(10);
|
||||
});
|
||||
|
||||
test("reports the missing amount in fixed mode", () => {
|
||||
@@ -51,7 +51,7 @@ describe("splitBalance", () => {
|
||||
{ postId: "events", value: 50, absorbsRemainder: false },
|
||||
],
|
||||
});
|
||||
expect(splitBalance(d)).toBe(30);
|
||||
expect(splitBalance(d, 2)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("toPurchase and fromPurchase", () => {
|
||||
|
||||
describe("finance plans", () => {
|
||||
test("withPlan generates equal slices from the given month", () => {
|
||||
const d = withPlan(draft({ amount: 3000 }), "2026-10", 6);
|
||||
const d = withPlan(draft({ amount: 3000 }), "2026-10", 6, 2);
|
||||
expect(d.plan!.slices).toHaveLength(6);
|
||||
expect(d.plan!.slices[0]).toEqual({
|
||||
month: "2026-10",
|
||||
@@ -140,37 +140,37 @@ describe("finance plans", () => {
|
||||
{ postId: "events", value: 30, absorbsRemainder: false },
|
||||
],
|
||||
});
|
||||
expect(withPlan(split, "2026-10", 2).splits).toEqual(split.splits);
|
||||
expect(withPlan(split, "2026-10", 2, 2).splits).toEqual(split.splits);
|
||||
});
|
||||
|
||||
test("withoutPlan clears the schedule", () => {
|
||||
expect(withoutPlan(withPlan(draft(), "2026-10", 3)).plan).toBeNull();
|
||||
expect(withoutPlan(withPlan(draft(), "2026-10", 3, 2)).plan).toBeNull();
|
||||
});
|
||||
|
||||
test("setSliceAmount edits one slice and leaves the others alone", () => {
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500);
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500);
|
||||
expect(d.plan!.slices.map((s) => s.amount.amount)).toEqual([1500, 1000, 1000]);
|
||||
});
|
||||
|
||||
test("planBalance reports the difference between the slices and the total", () => {
|
||||
const even = withPlan(draft({ amount: 3000 }), "2026-10", 3);
|
||||
expect(planBalance(even)).toBe(0);
|
||||
expect(planBalance(setSliceAmount(even, 0, 1500))).toBe(-500);
|
||||
const even = withPlan(draft({ amount: 3000 }), "2026-10", 3, 2);
|
||||
expect(planBalance(even, 2)).toBe(0);
|
||||
expect(planBalance(setSliceAmount(even, 0, 1500), 2)).toBe(-500);
|
||||
});
|
||||
|
||||
test("planBalance is zero when there is no plan", () => {
|
||||
expect(planBalance(draft())).toBe(0);
|
||||
expect(planBalance(draft(), 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("a plan with unbalanced slices is still valid", () => {
|
||||
// A deposit-then-instalments plan may legitimately not sum to the total
|
||||
// yet while the user is typing; the editor warns rather than blocking.
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500);
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500);
|
||||
expect(validatePurchase(d)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a plan with zero months is rejected", () => {
|
||||
expect(() => withPlan(draft(), "2026-10", 0)).toThrow(/at least one month/i);
|
||||
expect(() => withPlan(draft(), "2026-10", 0, 2)).toThrow(/at least one month/i);
|
||||
});
|
||||
|
||||
test("split×plan composition: each slice divides across posts in the split ratio", () => {
|
||||
@@ -189,6 +189,7 @@ describe("finance plans", () => {
|
||||
}),
|
||||
"2026-10",
|
||||
3,
|
||||
2,
|
||||
);
|
||||
expect(composed.splits).toEqual([
|
||||
{ postId: "games", value: 70, absorbsRemainder: true },
|
||||
|
||||
+15
-5
@@ -45,10 +45,10 @@ export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft {
|
||||
}
|
||||
|
||||
/** What the split editor must show: how far the parts are from the whole. */
|
||||
export function splitBalance(draft: PurchaseDraft): number {
|
||||
export function splitBalance(draft: PurchaseDraft, digits: number): number {
|
||||
const target = draft.splitMode === "percent" ? 100 : draft.amount;
|
||||
const sum = draft.splits.reduce((total, split) => total + split.value, 0);
|
||||
return roundMoney(target - sum, draft.currency);
|
||||
return roundMoney(target - sum, digits);
|
||||
}
|
||||
|
||||
export function validatePurchase(draft: PurchaseDraft): string[] {
|
||||
@@ -104,12 +104,18 @@ export function withPlan(
|
||||
draft: PurchaseDraft,
|
||||
startMonth: MonthId,
|
||||
months: number,
|
||||
digits: number,
|
||||
): PurchaseDraft {
|
||||
return {
|
||||
...draft,
|
||||
plan: {
|
||||
startMonth,
|
||||
slices: equalSlices({ amount: draft.amount, currency: draft.currency }, startMonth, months),
|
||||
slices: equalSlices(
|
||||
{ amount: draft.amount, currency: draft.currency },
|
||||
startMonth,
|
||||
months,
|
||||
digits,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -140,9 +146,13 @@ export function setSliceAmount(
|
||||
* slices and compare to the total" arithmetic has exactly one implementation.
|
||||
* Positive means the slices fall short of the purchase total.
|
||||
*/
|
||||
export function planBalance(draft: PurchaseDraft): number {
|
||||
export function planBalance(draft: PurchaseDraft, digits: number): number {
|
||||
if (!draft.plan) return 0;
|
||||
return slicesBalance({ amount: draft.amount, currency: draft.currency }, draft.plan.slices);
|
||||
return slicesBalance(
|
||||
{ amount: draft.amount, currency: draft.currency },
|
||||
draft.plan.slices,
|
||||
digits,
|
||||
);
|
||||
}
|
||||
|
||||
export function fromPurchase(purchase: Purchase): PurchaseDraft {
|
||||
|
||||
@@ -15,7 +15,13 @@ export function PostMonthRoute() {
|
||||
const row = monthView(dataset, monthId).rows.find((r) => r.post.id === postId);
|
||||
|
||||
const thisMonth = dataset.purchases.flatMap((purchase) => {
|
||||
const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates);
|
||||
const charges = chargesForPurchaseInMonth(
|
||||
purchase,
|
||||
monthId,
|
||||
base,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
const mine = charges.find((c) => c.postId === postId);
|
||||
return mine ? [{ purchase, amount: mine.amount }] : [];
|
||||
});
|
||||
|
||||
@@ -5,9 +5,17 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { setBaseCurrency, setFxRate, removeFxRate } from "../../../store/actions.ts";
|
||||
import {
|
||||
addCurrency,
|
||||
removeCurrency,
|
||||
setBaseCurrency,
|
||||
setFxRate,
|
||||
removeFxRate,
|
||||
updateCurrency,
|
||||
} from "../../../store/actions.ts";
|
||||
import { DEFAULT_FX_API_URL, fetchFxRates } from "../../../store/fxApi.ts";
|
||||
import { CURRENCIES, type Currency } from "../../../domain/types.ts";
|
||||
import { currencyUsage } from "../../../domain/currencies.ts";
|
||||
import type { Currency } from "../../../domain/types.ts";
|
||||
|
||||
export function CurrencySection() {
|
||||
const dataset = useDataset();
|
||||
@@ -15,7 +23,8 @@ export function CurrencySection() {
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL;
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const targets = CURRENCIES.filter((c) => c !== base);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const targets = dataset.currencies.map((c) => c.code).filter((c) => c !== base);
|
||||
|
||||
async function refresh() {
|
||||
setStatus("Fetching…");
|
||||
@@ -24,94 +33,198 @@ export function CurrencySection() {
|
||||
mutate((draft) => {
|
||||
for (const rate of rates) setFxRate(draft, rate);
|
||||
});
|
||||
setStatus(`Updated ${rates.length} rate(s).`);
|
||||
} catch (error) {
|
||||
const missing = targets.filter((t) => !rates.some((r) => r.currency === t));
|
||||
setStatus(
|
||||
`Updated ${rates.length} rate(s).` +
|
||||
(missing.length > 0
|
||||
? ` The service returned nothing for ${missing.join(", ")} — type those in by hand.`
|
||||
: ""),
|
||||
);
|
||||
} catch (cause) {
|
||||
// Fails soft: the cached rates are still in place, never cleared.
|
||||
setStatus(
|
||||
`Could not fetch rates (${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
cause instanceof Error ? cause.message : String(cause)
|
||||
}). Your saved rates are unchanged.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Currency">
|
||||
/** Currency edits throw on bad input, and a throw from a handler is invisible. */
|
||||
function tryEdit(change: () => void) {
|
||||
try {
|
||||
setError(null);
|
||||
change();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="base-currency">Base currency (all totals use this)</Label>
|
||||
return (
|
||||
<Section
|
||||
title="Currencies"
|
||||
hint="Add any currency you spend in. The code is how it is identified, so it cannot be changed later — and everything is converted to the base currency for totals."
|
||||
>
|
||||
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="mb-5 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="base-currency"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Base currency (all totals use this)
|
||||
</Label>
|
||||
<select
|
||||
id="base-currency"
|
||||
className="h-9 w-32 rounded border bg-background px-2 text-sm"
|
||||
className="h-9 w-40 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={base}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value as Currency;
|
||||
const currency = event.target.value;
|
||||
mutate((draft) => setBaseCurrency(draft, currency));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
{dataset.currencies.map(({ code }) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<table className="w-full max-w-md text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2">Currency</th>
|
||||
<th className="py-2 text-right">1 unit = ? {base}</th>
|
||||
<th className="py-2 text-right">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{targets.map((currency) => {
|
||||
const rate = dataset.fxRates.find((r) => r.currency === currency);
|
||||
return (
|
||||
<tr key={currency} className="border-b last:border-0">
|
||||
<td className="py-2">{currency}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
className="font-money ml-auto h-8 w-32"
|
||||
value={rate?.baseUnitsPerOne ?? ""}
|
||||
onChange={(event) => {
|
||||
// A cleared or non-positive field MUST remove the rate
|
||||
// row rather than store 0: toBase() converts a stored 0
|
||||
// silently to a 0-value conversion, while an absent row
|
||||
// correctly throws MissingRateError. Storing 0 here
|
||||
// would turn a loud, correct error into wrong numbers
|
||||
// presented as fact.
|
||||
const parsed = Number(event.target.value);
|
||||
mutate((draft) => {
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
setFxRate(draft, {
|
||||
currency,
|
||||
baseUnitsPerOne: parsed,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "manual",
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[42rem] text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Code</th>
|
||||
<th className="py-2 pl-4 font-medium">Name</th>
|
||||
<th className="py-2 pl-4 font-medium">Symbol</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">Decimals</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">1 unit = ? {base}</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">Source</th>
|
||||
<th className="py-2 pl-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataset.currencies.map((currency) => {
|
||||
const isBase = currency.code === base;
|
||||
const rate = dataset.fxRates.find((r) => r.currency === currency.code);
|
||||
const used = currencyUsage(dataset, currency.code);
|
||||
return (
|
||||
<tr
|
||||
key={currency.code}
|
||||
className="group border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60"
|
||||
>
|
||||
<td className="font-money py-2 font-medium">{currency.code}</td>
|
||||
<td className="py-2 pl-4">
|
||||
<Input
|
||||
className="h-8 w-40"
|
||||
aria-label={`Name for ${currency.code}`}
|
||||
value={currency.name ?? ""}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { name }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4">
|
||||
<Input
|
||||
className="font-money h-8 w-16"
|
||||
aria-label={`Symbol for ${currency.code}`}
|
||||
value={currency.symbol ?? ""}
|
||||
onChange={(event) => {
|
||||
const symbol = event.target.value;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { symbol }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money ml-auto h-8 w-16 text-right"
|
||||
aria-label={`Decimal places for ${currency.code}`}
|
||||
value={currency.digits}
|
||||
onChange={(event) => {
|
||||
const digits = Number(event.target.value);
|
||||
if (!Number.isInteger(digits) || digits < 0 || digits > 4) return;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { digits }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
{isBase ? (
|
||||
<span className="font-money text-budget-ink-muted">1</span>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
className="font-money ml-auto h-8 w-32 text-right"
|
||||
aria-label={`Rate for ${currency.code}`}
|
||||
value={rate?.baseUnitsPerOne ?? ""}
|
||||
onChange={(event) => {
|
||||
// A cleared or non-positive field MUST remove the rate
|
||||
// row rather than store 0: toBase() converts a stored 0
|
||||
// silently to a 0-value conversion, while an absent row
|
||||
// correctly throws MissingRateError. Storing 0 here
|
||||
// would turn a loud, correct error into wrong numbers
|
||||
// presented as fact.
|
||||
const parsed = Number(event.target.value);
|
||||
mutate((draft) => {
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
setFxRate(draft, {
|
||||
currency: currency.code,
|
||||
baseUnitsPerOne: parsed,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "manual",
|
||||
});
|
||||
} else {
|
||||
removeFxRate(draft, currency.code);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
removeFxRate(draft, currency);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs text-muted-foreground">
|
||||
{rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right text-xs text-budget-ink-muted">
|
||||
{isBase ? "—" : rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"}
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-overspend hover:text-overspend disabled:opacity-40"
|
||||
disabled={used.length > 0}
|
||||
title={
|
||||
used.length > 0
|
||||
? `In use by ${used.slice(0, 3).join(", ")}${used.length > 3 ? ` and ${used.length - 3} more` : ""}`
|
||||
: `Remove ${currency.code}`
|
||||
}
|
||||
onClick={() =>
|
||||
tryEdit(() => mutate((draft) => removeCurrency(draft, currency.code)))
|
||||
}
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fx-url">Rate service URL</Label>
|
||||
<AddCurrency onAdd={(def) => tryEdit(() => mutate((draft) => addCurrency(draft, def)))} />
|
||||
|
||||
<div className="mt-5 space-y-2 border-t border-budget-rule pt-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="fx-url"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Rate service URL
|
||||
</Label>
|
||||
<Input
|
||||
id="fx-url"
|
||||
className="max-w-xl"
|
||||
@@ -123,16 +236,102 @@ export function CurrencySection() {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-budget-ink-muted">
|
||||
{"{base}"} and {"{targets}"} are substituted. Fetching is optional — the app
|
||||
works offline with the rates you type in above.
|
||||
works offline with the rates you type in above, and a currency the service
|
||||
does not know can always be maintained by hand.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh()}>
|
||||
Fetch rates now
|
||||
</Button>
|
||||
{status && <p className="text-xs">{status}</p>}
|
||||
{status && <p className="text-xs text-budget-ink-muted">{status}</p>}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decimals default to 2 because almost every currency uses hundredths; the
|
||||
* field is there for the ones that do not (yen at 0, dinars at 3).
|
||||
*/
|
||||
function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number; symbol?: string; name?: string }) => void }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [symbol, setSymbol] = useState("");
|
||||
const [digits, setDigits] = useState("2");
|
||||
|
||||
const parsedDigits = Number(digits);
|
||||
const canAdd =
|
||||
/^[A-Za-z]{2,8}$/.test(code.trim()) &&
|
||||
Number.isInteger(parsedDigits) &&
|
||||
parsedDigits >= 0 &&
|
||||
parsedDigits <= 4;
|
||||
|
||||
function add() {
|
||||
onAdd({ code: code.trim(), digits: parsedDigits, symbol, name });
|
||||
setCode("");
|
||||
setName("");
|
||||
setSymbol("");
|
||||
setDigits("2");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||||
<Input
|
||||
className="font-money h-9 w-24 uppercase"
|
||||
placeholder="JPY"
|
||||
aria-label="New currency code"
|
||||
value={code}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setCode(next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="h-9 w-44"
|
||||
placeholder="Japanese yen"
|
||||
aria-label="New currency name"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setName(next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="font-money h-9 w-16"
|
||||
placeholder="¥"
|
||||
aria-label="New currency symbol"
|
||||
value={symbol}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setSymbol(next);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="new-digits"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Decimals
|
||||
</Label>
|
||||
<Input
|
||||
id="new-digits"
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money h-9 w-16 text-right"
|
||||
value={digits}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDigits(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" disabled={!canAdd} onClick={add}>
|
||||
Add currency
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
|
||||
import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts";
|
||||
import type { Currency, Post } from "../../../domain/types.ts";
|
||||
import { ruleAt } from "../../../domain/allocation.ts";
|
||||
import { currentMonth } from "../../../store/index.ts";
|
||||
import { RuleHistory } from "../../components/RuleHistory.tsx";
|
||||
@@ -72,7 +72,7 @@ export function PostsSection() {
|
||||
mutate((draft) => updatePost(draft, post.id, { currency }));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
{dataset.currencies.map(({ code: currency }) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
|
||||
Reference in New Issue
Block a user