feat: user-defined currencies, and amounts that carry their own currency
Two requests. The second is small; the first changed the money layer.
TYPING THE CURRENCY. The purchase total field now reads "30$" as thirty
dollars, "€25,50" as EUR 25.50, "250 kr" as DKK, "1.234,56 usd" as USD
1234.56, and sets the currency selector from what was typed. Entry speed is a
stated product feature and a second control per amount works against it.
The field is type="text", not type="number": a number input silently discards
anything non-numeric, so "30$" would never reach the parser. It keeps its own
text so a half-typed "30." stays as typed and only commits when it parses.
Separator handling is where parseMoneyInput earns its 26 tests. With both "."
and "," present the LAST is the decimal point, which covers "1.234,56" and
"1,234.56" without knowing a locale. With one, it groups thousands if exactly
three digits follow and is a decimal point otherwise — so "1,500" is fifteen
hundred and "1,5" is one and a half. Spaces group thousands only in
three-digit groups, because "30 40" is two amounts typed by mistake and
committing it as 3040 would be worse than rejecting it. An unrecognised
currency returns null rather than falling back: reading "30 GBP" as 30 DKK
would book money at the wrong currency.
USER-DEFINED CURRENCIES. `Currency` was a closed union and decimal places were
a module constant, so "add a currency" was a code change. Both now live in the
dataset: `Dataset.currencies` is the authority on which codes are valid, what
they are called, their symbol, and how many decimals they round to. Settings
grows a table to add, edit and remove them.
That forced the money layer open. `roundMoney` takes a DIGIT COUNT, not a
currency code, and every one of its ~29 call sites resolves digits from the
dataset via `digitsFor`. Making digits a required number rather than an
optional one with a default of 2 is the whole safety argument: every site
became a compile error the type checker enumerated for me, where a default
would have left each one silently correct for DKK/USD/EUR and silently wrong
for the first zero-decimal currency the owner adds.
Because every existing currency uses two decimals, a broken version of this
refactor would still pass all 356 previous tests. src/domain/currencyDigits.test.ts
is the oracle that cannot lie: a JPY-style zero-decimal currency driven through
roundMoney, both FX directions, both split modes, finance-plan division, the
fold, and the month view, asserting no figure has a fractional yen in it.
Guards that come with it, all tested:
- Codes are identity — they key the FX table and every stored Money — so they
are normalised (" jpy " -> "JPY") and cannot be edited, only added/removed.
- A currency in use cannot be removed, and the refusal names what refers to
it. The same reasoning as posts being archived rather than deleted: stored
Money carries the code, and dangling references would make the import
validator refuse the owner's own exported file. Removing an unused one takes
its exchange rate with it, leaving no orphan row.
- The import validator now checks the currency table itself — codes, duplicate
codes, integer decimals 0-4 — and validates every Money against it. That
boundary check replaces the compile-time safety the closed union provided.
- parseFxResponse takes the accepted codes instead of a global constant, so a
rate service returning dozens cannot put unreachable rows in the table.
Migration v2 -> v3 seeds the currency table. Behaviour-preserving by
construction: every currency that existed had two decimal places and the
seeded table records two, so not one stored amount can round differently than
it did before. An existing `currencies` field is kept rather than overwritten,
since a hand-edited file could already carry currencies the owner added.
Verified in the browser, not just in tests: JPY added through the real Settings
form with 0 decimals, and the five inputs above typed into the real purchase
field, each setting the right currency.
Two obsolete test helpers went away. plans.test.ts and actions.test.ts used to
monkey-patch the CURRENCY_DIGITS constant to prove the table was consulted;
digits are an argument now, so those tests just ask for zero decimals.
types.test.ts no longer asserts "the supported currencies are exactly DKK,
USD, EUR" — that stopped being a property of the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9243c8586b
commit
cdece63c68
45 files changed
+1615
-309
No files matched your search
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user