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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+7 -1
View File
@@ -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 }] : [];
});
+270 -71
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>