feat(domain): allocation rules become a dated series

Post.standingRule becomes Post.rules: RuleVersion[], resolved by ruleAt() as
the last version effective at or before a month. Per-month overrides still win
outright; a post with no applicable version allocates zero rather than
applying its earliest rule backwards.

allocationFor keeps its signature, so the fold, the views and the ODS export
are untouched. Settings gets a read-only rule summary for now; the history
editor follows.
This commit is contained in:
Lucas Winther
2026-09-02 02:15:34 +02:00
parent c74a66aea9
commit 68712e72a3
11 changed files with 252 additions and 151 deletions
+172 -35
View File
@@ -1,18 +1,26 @@
import { test, expect, describe } from "bun:test";
import {
resolveRule,
ruleAt,
ruleForPost,
isOverridden,
allocationFor,
incomeFor,
} from "./allocation.ts";
import type { Dataset, FxRate, Post } from "./types.ts";
import { foldBalances, figuresFor } from "./fold.ts";
import type { Dataset, FxRate, Post, Rule } from "./types.ts";
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" },
];
const pct = (percent: number): Rule => ({ kind: "percentOfIncome", percent });
const fixed = (amount: number): Rule => ({
kind: "fixed",
amount: { amount, currency: "DKK" },
});
function post(overrides: Partial<Post> = {}): Post {
return {
id: "p1",
@@ -20,18 +28,18 @@ function post(overrides: Partial<Post> = {}): Post {
order: 0,
archived: false,
currency: "DKK",
standingRule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } },
rules: [{ from: "2026-01", rule: fixed(400) }],
...overrides,
};
}
function dataset(overrides: Partial<Dataset> = {}): Dataset {
return {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 },
fxRates: rates,
posts: [post()],
months: [
{ id: "2026-09", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
],
purchases: [],
...overrides,
@@ -40,9 +48,7 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
describe("resolveRule", () => {
test("a fixed rule in base currency is its amount", () => {
expect(
resolveRule({ kind: "fixed", amount: { amount: 400, currency: "DKK" } }, 20000, "DKK", rates),
).toBe(400);
expect(resolveRule(fixed(400), 20000, "DKK", rates)).toBe(400);
});
test("a fixed rule in a foreign currency converts to base", () => {
@@ -52,50 +58,113 @@ describe("resolveRule", () => {
});
test("a percentage rule resolves against income", () => {
expect(resolveRule({ kind: "percentOfIncome", percent: 25 }, 20000, "DKK", rates)).toBe(5000);
});
test("a percentage rule rounds", () => {
expect(resolveRule({ kind: "percentOfIncome", percent: 33 }, 1000.5, "DKK", rates)).toBe(330.17);
expect(resolveRule(pct(25), 20000, "DKK", rates)).toBe(5000);
});
test("a percentage above 100 is permitted", () => {
expect(resolveRule({ kind: "percentOfIncome", percent: 150 }, 1000, "DKK", rates)).toBe(1500);
expect(resolveRule(pct(150), 1000, "DKK", rates)).toBe(1500);
});
});
describe("override precedence", () => {
test("a month override wins over the standing rule", () => {
describe("ruleAt", () => {
const p = post({
rules: [
{ from: "2026-04", rule: pct(10) },
{ from: "2026-07", rule: pct(15) },
],
});
test("returns null before the earliest version", () => {
expect(ruleAt(p, "2026-03")).toBeNull();
});
test("applies from the version's own month", () => {
expect(ruleAt(p, "2026-04")?.rule).toEqual(pct(10));
});
test("keeps applying between versions", () => {
expect(ruleAt(p, "2026-06")?.rule).toEqual(pct(10));
});
test("the later version takes over from its month", () => {
expect(ruleAt(p, "2026-07")?.rule).toEqual(pct(15));
});
test("the last version continues indefinitely", () => {
expect(ruleAt(p, "2030-11")?.rule).toEqual(pct(15));
});
test("a post with no versions has no rule at all", () => {
expect(ruleAt(post({ rules: [] }), "2026-06")).toBeNull();
});
test("resolves correctly even if the array is not sorted", () => {
const unsorted = post({
rules: [
{ from: "2026-07", rule: pct(15) },
{ from: "2026-04", rule: pct(10) },
],
});
expect(ruleAt(unsorted, "2026-05")?.rule).toEqual(pct(10));
expect(ruleAt(unsorted, "2026-08")?.rule).toEqual(pct(15));
});
});
describe("precedence", () => {
test("a month override beats the effective version", () => {
const data = dataset({
posts: [post({ rules: [{ from: "2026-01", rule: fixed(400) }] })],
months: [
{
id: "2026-09",
id: "2026-01",
income: { amount: 20000, currency: "DKK" },
ruleOverrides: { p1: { kind: "fixed", amount: { amount: 999, currency: "DKK" } } },
ruleOverrides: { p1: fixed(999) },
},
],
});
expect(allocationFor(data, "p1", "2026-09")).toBe(999);
expect(allocationFor(data, "p1", "2026-01")).toBe(999);
expect(isOverridden(data.months[0], "p1")).toBe(true);
});
test("without an override the standing rule applies", () => {
const data = dataset();
expect(allocationFor(data, "p1", "2026-09")).toBe(400);
expect(isOverridden(data.months[0], "p1")).toBe(false);
test("without an override the effective version applies", () => {
expect(allocationFor(dataset(), "p1", "2026-01")).toBe(400);
});
test("ruleForPost falls back to the standing rule when the month is unknown", () => {
expect(ruleForPost(undefined, post())).toEqual({
kind: "fixed",
amount: { amount: 400, currency: "DKK" },
test("no applicable version means an allocation of zero", () => {
const data = dataset({ posts: [post({ rules: [{ from: "2026-06", rule: fixed(400) }] })] });
expect(allocationFor(data, "p1", "2026-01")).toBe(0);
});
test("a post with no versions allocates zero rather than throwing", () => {
expect(allocationFor(dataset({ posts: [post({ rules: [] })] }), "p1", "2026-01")).toBe(0);
});
test("an override still applies in a month with no version", () => {
const data = dataset({
posts: [post({ rules: [] })],
months: [
{
id: "2026-01",
income: { amount: 20000, currency: "DKK" },
ruleOverrides: { p1: fixed(250) },
},
],
});
expect(allocationFor(data, "p1", "2026-01")).toBe(250);
});
test("ruleForPost returns null when nothing applies", () => {
expect(ruleForPost(undefined, post({ rules: [] }), "2026-01")).toBeNull();
});
test("an unknown post id throws", () => {
expect(() => allocationFor(dataset(), "nope", "2026-01")).toThrow(/nope/);
});
});
describe("incomeFor", () => {
test("returns the month's income", () => {
expect(incomeFor(dataset(), "2026-09")).toBe(20000);
expect(incomeFor(dataset(), "2026-01")).toBe(20000);
});
test("a month with no record has zero income", () => {
@@ -103,13 +172,81 @@ describe("incomeFor", () => {
});
});
test("a percentage allocation in a month with no record is zero", () => {
const data = dataset({
posts: [post({ standingRule: { kind: "percentOfIncome", percent: 25 } })],
});
expect(allocationFor(data, "p1", "2026-10")).toBe(0);
});
describe("the fold across a rule change", () => {
// The feature's headline behaviour: 10% through June, 15% from July, with the
// rollover carrying correctly across the boundary.
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2 },
fxRates: [],
posts: [
post({
rules: [
{ from: "2026-04", rule: pct(10) },
{ from: "2026-07", rule: pct(15) },
],
}),
],
months: ["2026-05", "2026-06", "2026-07", "2026-08"].map((id) => ({
id,
income: { amount: 10000, currency: "DKK" },
ruleOverrides: {},
})),
purchases: [],
};
test("an unknown post id throws", () => {
expect(() => allocationFor(dataset(), "nope", "2026-09")).toThrow(/nope/);
test("the allocation steps up in the version's month", () => {
const fold = foldBalances(data, "2026-08");
expect(figuresFor(fold, "p1", "2026-06").allocation).toBe(1000);
expect(figuresFor(fold, "p1", "2026-07").allocation).toBe(1500);
});
test("unspent budget carries across the change", () => {
const fold = foldBalances(data, "2026-08");
// 1000 + 1000 = 2000 carried into July, + 1500 = 3500, + 1500 = 5000.
expect(figuresFor(fold, "p1", "2026-07").carriedIn).toBe(2000);
expect(figuresFor(fold, "p1", "2026-08").remaining).toBe(5000);
});
test("a version dated before the fold start is legal but inert", () => {
// The fold never evaluates months before its start, so such a version
// simply has no effect until the fold start moves earlier.
const early: Dataset = {
...data,
posts: [
post({
rules: [
{ from: "2020-01", rule: pct(99) },
{ from: "2026-04", rule: pct(10) },
],
}),
],
};
const fold = foldBalances(early, "2026-06");
expect(figuresFor(fold, "p1", "2026-05").allocation).toBe(1000);
expect(fold.has("2020-01")).toBe(false);
});
test("an archived post still resolves its rules, because it still folds", () => {
const archived: Dataset = {
...data,
posts: [post({ archived: true, rules: [{ from: "2026-05", rule: pct(10) }] })],
};
const fold = foldBalances(archived, "2026-06");
expect(figuresFor(fold, "p1", "2026-05").allocation).toBe(1000);
expect(figuresFor(fold, "p1", "2026-06").carriedIn).toBe(1000);
});
test("months before the earliest version allocate nothing", () => {
const early: Dataset = {
...data,
settings: { ...data.settings, foldStartMonth: "2026-02" },
months: [
{ id: "2026-02", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
...data.months,
],
};
const fold = foldBalances(early, "2026-05");
expect(figuresFor(fold, "p1", "2026-02").allocation).toBe(0);
expect(figuresFor(fold, "p1", "2026-05").allocation).toBe(1000);
});
});
+35 -3
View File
@@ -1,5 +1,6 @@
import { toBase } from "./fx.ts";
import { roundMoney } from "./money.ts";
import { compareMonths } from "./months.ts";
import type {
Currency,
Dataset,
@@ -9,6 +10,7 @@ import type {
Post,
PostId,
Rule,
RuleVersion,
} from "./types.ts";
/** Resolves a rule to a base-currency amount. Percentages may exceed 100. */
@@ -24,8 +26,36 @@ export function resolveRule(
return roundMoney((income * rule.percent) / 100, baseCurrency);
}
export function ruleForPost(month: Month | undefined, post: Post): Rule {
return month?.ruleOverrides[post.id] ?? post.standingRule;
/**
* The version in effect for `monthId`: the last one whose `from` is at or
* before it, or null when the post was not yet budgeted.
*
* Scans rather than assuming sorted order. The array is kept sorted by
* `setRuleFrom`, but a hand-edited import must still resolve correctly.
*/
export function ruleAt(post: Post, monthId: MonthId): RuleVersion | null {
let effective: RuleVersion | null = null;
for (const version of post.rules) {
if (compareMonths(version.from, monthId) > 0) continue;
if (effective === null || compareMonths(version.from, effective.from) > 0) {
effective = version;
}
}
return effective;
}
/**
* Precedence: a one-month override wins outright; otherwise the version in
* effect; otherwise nothing, which the caller renders as an allocation of zero.
*/
export function ruleForPost(
month: Month | undefined,
post: Post,
monthId: MonthId,
): Rule | null {
const override = month?.ruleOverrides[post.id];
if (override) return override;
return ruleAt(post, monthId)?.rule ?? null;
}
export function isOverridden(month: Month | undefined, postId: PostId): boolean {
@@ -46,8 +76,10 @@ export function allocationFor(
const post = dataset.posts.find((p) => p.id === postId);
if (!post) throw new Error(`Unknown post: ${postId}`);
const month = dataset.months.find((m) => m.id === monthId);
const rule = ruleForPost(month, post, monthId);
if (rule === null) return 0;
return resolveRule(
ruleForPost(month, post),
rule,
incomeFor(dataset, monthId),
dataset.settings.baseCurrency,
dataset.fxRates,
+2 -2
View File
@@ -9,7 +9,7 @@ function post(id: string, fixed: number, archived = false): Post {
order: 0,
archived,
currency: "DKK",
standingRule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } },
rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } } }],
};
}
@@ -114,7 +114,7 @@ test("percentage allocations follow the month's income", () => {
order: 0,
archived: false,
currency: "DKK",
standingRule: { kind: "percentOfIncome", percent: 10 },
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }],
},
],
months: [
+2 -5
View File
@@ -12,14 +12,11 @@ test("seeds exactly the three named posts, in order", () => {
expect(data.posts.map((p) => p.order)).toEqual([0, 1, 2]);
});
test("seeded posts are active with a zero fixed standing rule", () => {
test("seeded posts are active and unbudgeted", () => {
const data = createSeedDataset("2026-09");
for (const post of data.posts) {
expect(post.archived).toBe(false);
expect(post.standingRule).toEqual({
kind: "fixed",
amount: { amount: 0, currency: "DKK" },
});
expect(post.rules).toEqual([]);
}
});
+4 -2
View File
@@ -1,6 +1,6 @@
import type { Dataset, MonthId, Post } from "./types.ts";
export const SCHEMA_VERSION = 1;
export const SCHEMA_VERSION = 2;
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
@@ -19,7 +19,9 @@ export function createSeedDataset(startMonth: MonthId): Dataset {
order,
archived: false,
currency: "DKK",
standingRule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } },
// A new user has genuinely not budgeted anything yet. This is a different
// state from a rule of zero, and the app distinguishes them.
rules: [],
}));
return {
+12 -1
View File
@@ -28,6 +28,12 @@ export type Rule =
| { kind: "fixed"; amount: Money }
| { kind: "percentOfIncome"; percent: number };
export interface RuleVersion {
/** The rule takes effect in this month and continues until the next version. */
from: MonthId;
rule: Rule;
}
export interface Post {
id: PostId;
name: string;
@@ -35,7 +41,12 @@ export interface Post {
archived: boolean;
/** Display currency for this post's own views only. All totals use base. */
currency: Currency;
standingRule: Rule;
/**
* The post's allocation rule over time, sorted ascending by `from`, at most
* one entry per month. Empty means the post has never been budgeted, and its
* allocation is zero — not the same as a rule of zero.
*/
rules: RuleVersion[];
}
export interface Month {
+1 -1
View File
@@ -9,7 +9,7 @@ function post(id: string, order: number, percent: number, archived = false): Pos
order,
archived,
currency: "DKK",
standingRule: { kind: "percentOfIncome", percent },
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent } }],
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ import type { Dataset } from "../domain/types.ts";
function populated(): Dataset {
const data = createSeedDataset("2026-01");
data.months[0]!.income = { amount: 20000, currency: "DKK" };
data.posts[1]!.standingRule = { kind: "percentOfIncome", percent: 25 };
data.posts[1]!.rules = [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 25 } }];
data.purchases.push({
id: "p1",
date: "2026-01-15",
+4 -42
View File
@@ -77,34 +77,16 @@ describe("months", () => {
describe("posts", () => {
test("addPost appends with the next order value", () => {
const data = draft();
const post = actions.addPost(data, "Travel", "EUR", {
kind: "percentOfIncome",
percent: 5,
});
const post = actions.addPost(data, "Travel", "EUR");
expect(post.order).toBe(3);
expect(post.archived).toBe(false);
expect(data.posts).toHaveLength(4);
});
test("addPost rounds a fixed standing rule's amount and a percent's percent", () => {
test("addPost starts the post unbudgeted", () => {
const data = draft();
const fixedPost = actions.addPost(data, "Rent", "DKK", {
kind: "fixed",
amount: { amount: 12.345, currency: "DKK" },
});
expect(fixedPost.standingRule).toEqual({
kind: "fixed",
amount: { amount: 12.35, currency: "DKK" },
});
const percentPost = actions.addPost(data, "Travel", "EUR", {
kind: "percentOfIncome",
percent: 12.3456,
});
expect(percentPost.standingRule).toEqual({
kind: "percentOfIncome",
percent: 12.35,
});
const post = actions.addPost(data, "Travel", "EUR");
expect(post.rules).toEqual([]);
});
test("setPostArchived toggles without deleting", () => {
@@ -140,26 +122,6 @@ describe("posts", () => {
expect(post.currency).toBe("DKK");
});
test("updatePost rounds a fixed standing rule's amount and a percent's percent", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.updatePost(data, postId, {
standingRule: { kind: "fixed", amount: { amount: 12.345, currency: "DKK" } },
});
expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({
kind: "fixed",
amount: { amount: 12.35, currency: "DKK" },
});
actions.updatePost(data, postId, {
standingRule: { kind: "percentOfIncome", percent: 12.3456 },
});
expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({
kind: "percentOfIncome",
percent: 12.35,
});
});
test("an unknown post id throws", () => {
expect(() => actions.updatePost(draft(), "ghost", { name: "x" })).toThrow(/ghost/);
});
+2 -10
View File
@@ -102,19 +102,14 @@ export function setRuleOverride(
month.ruleOverrides[postId] = roundRule(rule);
}
export function addPost(
draft: Dataset,
name: string,
currency: Currency,
standingRule: Rule,
): Post {
export function addPost(draft: Dataset, name: string, currency: Currency): Post {
const post: Post = {
id: newId(),
name,
order: draft.posts.length,
archived: false,
currency,
standingRule: roundRule(standingRule),
rules: [],
};
draft.posts.push(post);
return post;
@@ -127,9 +122,6 @@ export function updatePost(
): void {
const post = requirePost(draft, postId);
const resolved: Partial<Omit<Post, "id">> = { ...changes };
if (changes.standingRule) {
resolved.standingRule = roundRule(changes.standingRule);
}
Object.assign(post, resolved);
}
+17 -49
View File
@@ -4,7 +4,9 @@ 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 Rule } from "../../../domain/types.ts";
import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts";
import { ruleAt } from "../../../domain/allocation.ts";
import { currentMonth } from "../../../store/index.ts";
export function PostsSection() {
const dataset = useDataset();
@@ -13,48 +15,19 @@ export function PostsSection() {
const ordered = [...dataset.posts].sort((a, b) => a.order - b.order);
const base = dataset.settings.baseCurrency;
function ruleEditor(postId: string, rule: Rule) {
function ruleSummary(post: Post) {
const effective = ruleAt(post, currentMonth);
if (!effective) {
return <span className="text-xs text-muted-foreground">not budgeted</span>;
}
const { rule } = effective;
return (
<div className="flex items-center gap-2">
<select
className="h-8 rounded border bg-background px-1 text-xs"
value={rule.kind}
onChange={(event) => {
const kind = event.target.value;
mutate((draft) =>
updatePost(draft, postId, {
standingRule:
kind === "fixed"
? { kind: "fixed", amount: { amount: 0, currency: base } }
: { kind: "percentOfIncome", percent: 0 },
}),
);
}}
>
<option value="fixed">fixed amount</option>
<option value="percentOfIncome">% of income</option>
</select>
<Input
type="number"
step="0.01"
className="font-money h-8 w-28"
value={rule.kind === "fixed" ? rule.amount.amount : rule.percent}
onChange={(event) => {
const value = Number(event.target.value) || 0;
mutate((draft) =>
updatePost(draft, postId, {
standingRule:
rule.kind === "fixed"
? { kind: "fixed", amount: { ...rule.amount, amount: value } }
: { kind: "percentOfIncome", percent: value },
}),
);
}}
/>
<span className="w-10 text-xs text-muted-foreground">
{rule.kind === "fixed" ? rule.amount.currency : "%"}
</span>
</div>
<span className="text-xs">
{rule.kind === "fixed"
? `${rule.amount.amount} ${rule.amount.currency}`
: `${rule.percent}% of income`}
<span className="ml-1 text-muted-foreground">from {effective.from}</span>
</span>
);
}
@@ -105,7 +78,7 @@ export function PostsSection() {
))}
</select>
</td>
<td className="py-2">{ruleEditor(post.id, post.standingRule)}</td>
<td className="py-2">{ruleSummary(post)}</td>
<td className="py-2 text-right">
<Button size="sm" variant="ghost" disabled={index === 0}
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
@@ -140,12 +113,7 @@ export function PostsSection() {
variant="outline"
disabled={newName.trim() === ""}
onClick={() => {
mutate((draft) =>
addPost(draft, newName.trim(), base, {
kind: "fixed",
amount: { amount: 0, currency: base },
}),
);
mutate((draft) => addPost(draft, newName.trim(), base));
setNewName("");
}}
>