feat: add budget folder schema and migration

This commit is contained in:
Lucas Winther committed 2026-09-29 20:25:47 +02:00
1 parent 67c77eb247
commit c34aa846b9
19 files changed
+227 -6

No files matched your search

+4
View File
@@ -47,6 +47,7 @@ actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amou
// every combination at once.
const wowSub = actions.addRecurringCost(d, {
name: "World of Warcraft",
folderId: null,
archived: false,
amount: { amount: 13, currency: "USD" },
startDate: "2026-06-05",
@@ -57,6 +58,7 @@ const wowSub = actions.addRecurringCost(d, {
});
actions.addRecurringCost(d, {
name: "Rent",
folderId: null,
archived: false,
amount: { amount: 8200, currency: "DKK" },
startDate: "2026-06",
@@ -67,6 +69,7 @@ actions.addRecurringCost(d, {
});
actions.addRecurringCost(d, {
name: "Veg box",
folderId: null,
archived: false,
amount: { amount: 175, currency: "DKK" },
startDate: "2026-06-06",
@@ -79,6 +82,7 @@ actions.addRecurringCost(d, {
// off-schedule payment moves the whole series — the phone-bill behaviour.
actions.addRecurringCost(d, {
name: "Car insurance",
folderId: null,
archived: false,
amount: { amount: 349, currency: "DKK" },
startDate: "2026-08-14",
+3
View File
@@ -36,6 +36,7 @@ function post(overrides: Partial<Post> = {}): Post {
id: "p1",
name: "Food",
order: 0,
folderId: null,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: fixed(400) }],
@@ -48,6 +49,7 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: rates,
folders: [],
posts: [post()],
months: [
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
@@ -191,6 +193,7 @@ describe("the fold across a rule change", () => {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: [],
folders: [],
posts: [
post({
rules: [
+4
View File
@@ -214,6 +214,7 @@ describe("chargesForMonth", () => {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: rates,
folders: [],
posts: [],
months: [],
purchases: [
@@ -233,6 +234,7 @@ describe("chargesForMonth", () => {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: rates,
folders: [],
posts: [],
months: [],
purchases: [],
@@ -251,6 +253,7 @@ describe("expectedForMonth", () => {
{ code: "USD", symbol: "$", name: "US dollar" },
],
fxRates: [{ currency: "USD", baseUnitsPerOne: 7, updatedAt: "2026-01-01", source: "manual" }],
folders: [],
posts: [],
months: [],
purchases,
@@ -262,6 +265,7 @@ describe("expectedForMonth", () => {
id: "r1",
name: "Rent",
order: 0,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
+4
View File
@@ -96,11 +96,13 @@ describe("the fold at zero decimals", () => {
fxRates: [
{ currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" },
],
folders: [],
posts: [
{
id: "food",
name: "Food",
order: 0,
folderId: null,
archived: false,
currency: "JPY",
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 33.333 } }],
@@ -113,6 +115,7 @@ describe("the fold at zero decimals", () => {
id: "fun",
name: "Fun",
order: 1,
folderId: null,
archived: false,
currency: "JPY",
rules: [],
@@ -134,6 +137,7 @@ describe("the fold at zero decimals", () => {
id: "gym",
name: "Gym",
order: 0,
folderId: null,
archived: false,
amount: { amount: 7, currency: "USD" },
startDate: "2026-01",
+6
View File
@@ -18,6 +18,7 @@ function post(id: string, fixed: number, archived = false): Post {
id,
name: id,
order: 0,
folderId: null,
archived,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } } }],
@@ -41,6 +42,7 @@ function dataset(posts: Post[], purchases: Purchase[]): Dataset {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: [],
folders: [],
posts,
months: [
{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
@@ -131,6 +133,7 @@ test("percentage allocations follow the month's income", () => {
id: "food",
name: "Food",
order: 0,
folderId: null,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }],
@@ -191,11 +194,13 @@ function datasetWithRent(): Dataset {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [],
folders: [],
posts: [
{
id: "housing",
name: "Housing",
order: 0,
folderId: null,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 10000, currency: "DKK" } } }],
@@ -208,6 +213,7 @@ function datasetWithRent(): Dataset {
id: "rent",
name: "Rent",
order: 0,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
+2
View File
@@ -7,6 +7,7 @@ function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
id: "r1",
name: "Rent",
order: 0,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -248,6 +249,7 @@ describe("occurrencesByMonth", () => {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [],
folders: [],
posts: [],
months: [],
purchases,
+6
View File
@@ -43,3 +43,9 @@ test("the seed dataset renders a valid month view", () => {
test("newId returns distinct ids", () => {
expect(newId()).not.toBe(newId());
});
test("fresh dataset starts Unfiled", () => {
const data = createSeedDataset("2026-09");
expect(data.folders).toEqual([]);
expect(data.posts.every((post) => post.folderId === null)).toBe(true);
});
+3 -1
View File
@@ -1,7 +1,7 @@
import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts";
import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts";
export const SCHEMA_VERSION = 7;
export const SCHEMA_VERSION = 8;
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
@@ -24,6 +24,7 @@ export function createSeedDataset(
id: newId(),
name,
order,
folderId: null,
archived: false,
currency: "DKK",
// A new user has genuinely not budgeted anything yet. This is a different
@@ -42,6 +43,7 @@ export function createSeedDataset(
// 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 })),
folders: [],
posts,
months: [
{ id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} },
+11
View File
@@ -54,6 +54,14 @@ export type MonthId = string;
export type IsoDate = string;
export type PostId = string;
export type PurchaseId = string;
export type FolderId = string;
export interface BudgetFolder {
id: FolderId;
name: string;
parentId: FolderId | null;
order: number;
}
export type Rule =
| { kind: "fixed"; amount: Money }
@@ -69,6 +77,7 @@ export interface Post {
id: PostId;
name: string;
order: number;
folderId: FolderId | null;
archived: boolean;
/** Display currency for this post's own views only. All totals use base. */
currency: Currency;
@@ -138,6 +147,7 @@ export interface RecurringCost {
id: RecurringCostId;
name: string;
order: number;
folderId: FolderId | null;
/**
* A UI flag ONLY. Projection is governed by `startDate` and `endedFrom`,
* exactly as an archived post still folds — archiving must not retroactively
@@ -221,6 +231,7 @@ export interface Dataset {
*/
currencies: CurrencyDef[];
fxRates: FxRate[];
folders: BudgetFolder[];
posts: Post[];
months: Month[];
purchases: Purchase[];
+3
View File
@@ -17,6 +17,7 @@ function post(id: string, order: number, percent: number, archived = false): Pos
id,
name: id,
order,
folderId: null,
archived,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent } }],
@@ -39,6 +40,7 @@ const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS },
currencies: TEST_CURRENCIES,
fxRates: [],
folders: [],
posts: [post("food", 0, 20), post("games", 1, 10)],
months: [
{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
@@ -137,6 +139,7 @@ describe("monthView", () => {
id: "r1",
name: "Storage unit",
order: 0,
folderId: null,
archived: false,
amount: { amount: 400, currency: "DKK" },
startDate: "2026-01",
+54 -2
View File
@@ -114,6 +114,57 @@ describe("validation", () => {
});
});
describe("import rejects invalid folder graph", () => {
const folder = (id: string, parentId: string | null, order: number) => ({ id, name: id, parentId, order });
const parse = (data: Dataset) => parseDatasetJson(JSON.stringify(data));
test("valid nested tree parses", () => {
const data = populated();
data.folders = [folder("home", null, 0), folder("bills", "home", 0)];
data.posts[0]!.folderId = "bills";
expect(parse(data).posts[0]!.folderId).toBe("bills");
});
test("rejects duplicate folder IDs", () => {
const data = populated();
data.folders = [folder("same", null, 0), folder("same", null, 1)];
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects missing parent", () => {
const data = populated();
data.folders = [folder("child", "missing", 0)];
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects missing item folders", () => {
const data = populated();
data.posts[0]!.folderId = "missing";
expect(() => parse(data)).toThrow(ImportValidationError);
data.posts[0]!.folderId = null;
data.recurring.push({
id: "rent", name: "Rent", order: 3, folderId: "missing", archived: false,
amount: { amount: 100, currency: "DKK" }, startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
});
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects cycles", () => {
const data = populated();
data.folders = [folder("a", "b", 0), folder("b", "a", 0)];
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects duplicate sibling order across item kinds", () => {
const data = populated();
data.folders = [folder("bills", null, 0)];
data.posts[0]!.order = 0;
expect(() => parse(data)).toThrow(ImportValidationError);
});
});
test("describeDataset counts what an import would replace", () => {
expect(describeDataset(populated())).toEqual({
posts: 3,
@@ -286,7 +337,8 @@ describe("recurring cost validation", () => {
const valid = {
id: "r1",
name: "Rent",
order: 0,
order: 3,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -530,7 +582,7 @@ describe("Purchase.source validation", () => {
test("a source with a malformed occurrence date is refused", () => {
const data: any = JSON.parse(withSource({ recurringId: "r1", occurrenceDate: "nope" }));
data.recurring = [{
id: "r1", name: "Rent", order: 0, archived: false,
id: "r1", name: "Rent", order: 3, folderId: null, archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 },
+67 -1
View File
@@ -1,4 +1,4 @@
import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts";
import type { Currency, CurrencyDef, Dataset, FolderId, MonthId } from "../domain/types.ts";
import { migrate } from "../store/migrations.ts";
import { monthOf } from "../domain/months.ts";
import { toDayOrdinal } from "../domain/days.ts";
@@ -132,6 +132,71 @@ function requireArray(data: Record<string, unknown>, key: string): unknown[] {
return value;
}
function requireFolderTree(data: Dataset): void {
const folders = requireArray(data as unknown as Record<string, unknown>, "folders");
const parents = new Map<FolderId, FolderId | null>();
const siblingOrders = new Map<FolderId | null, Set<number>>();
const requireOrder = (order: unknown, label: string) => {
if (typeof order !== "number" || !Number.isFinite(order) || !Number.isInteger(order)) {
throw new ImportValidationError(`${label} has an invalid order`);
}
};
const requireLocation = (folderId: unknown, order: unknown, label: string) => {
if (folderId !== null && (typeof folderId !== "string" || !parents.has(folderId))) {
throw new ImportValidationError(`${label} references unknown folder "${String(folderId)}"`);
}
requireOrder(order, label);
const key = folderId as FolderId | null;
const orders = siblingOrders.get(key) ?? new Set<number>();
if (orders.has(order as number)) {
throw new ImportValidationError(`${label} duplicates sibling order ${String(order)}`);
}
orders.add(order as number);
siblingOrders.set(key, orders);
};
for (const entry of folders) {
const folder = entry as Record<string, unknown> | null;
if (!folder || typeof folder.id !== "string" || !folder.id.trim()) {
throw new ImportValidationError("Folder has an invalid id");
}
if (parents.has(folder.id)) {
throw new ImportValidationError(`Duplicate folder id "${folder.id}"`);
}
if (typeof folder.name !== "string" || !folder.name.trim()) {
throw new ImportValidationError(`Folder "${folder.id}" has an empty name`);
}
if (folder.parentId !== null && typeof folder.parentId !== "string") {
throw new ImportValidationError(`Folder "${folder.id}" has an invalid parent`);
}
parents.set(folder.id, folder.parentId as FolderId | null);
}
for (const [id, parentId] of parents) {
if (parentId !== null && !parents.has(parentId)) {
throw new ImportValidationError(`Folder "${id}" references unknown parent "${parentId}"`);
}
const visited = new Set<FolderId>();
let cursor: FolderId | null = id;
while (cursor !== null) {
if (visited.has(cursor)) throw new ImportValidationError(`Folder cycle at "${cursor}"`);
visited.add(cursor);
cursor = parents.get(cursor) ?? null;
}
}
for (const entry of folders) {
const folder = entry as Record<string, unknown>;
requireLocation(folder.parentId, folder.order, `Folder "${folder.id}"`);
}
for (const post of data.posts) {
requireLocation(post?.folderId, post?.order, `Post "${post?.name}"`);
}
for (const cost of data.recurring) {
requireLocation(cost?.folderId, cost?.order, `Recurring cost "${cost?.name}"`);
}
}
/**
* `n` is checked hard rather than clamped. The projection walk in
* `domain/occurrences.ts` terminates only if every step strictly advances, and
@@ -331,6 +396,7 @@ export function parseDatasetJson(text: string): Dataset {
const recurring = requireArray(data, "recurring") as any[];
dataset.recurring = recurring;
requireFolderTree(dataset);
// Optional chaining, not `.id` directly: a null/non-object entry must fall
// through to the loop below and fail there with an ImportValidationError,
// not throw a raw TypeError here while merely collecting ids.
+3
View File
@@ -464,6 +464,7 @@ describe("recurring costs", () => {
const rentInput = {
name: "Rent",
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -764,6 +765,7 @@ describe("confirmOccurrence", () => {
data.posts[0]!.id = "housing";
actions.addRecurringCost(data, {
name: "Rent",
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -836,6 +838,7 @@ describe("editing a confirmation's date", () => {
data.posts[0]!.id = "housing";
actions.addRecurringCost(data, {
name: "Car insurance",
folderId: null,
archived: false,
amount: { amount: 900, currency: "DKK" },
startDate: "2026-08-14",
+1
View File
@@ -134,6 +134,7 @@ export function addPost(draft: Dataset, name: string, currency: Currency): Post
id: newId(),
name,
order: draft.posts.length,
folderId: null,
archived: false,
currency,
rules: [],
+32 -2
View File
@@ -193,7 +193,7 @@ describe("v2 -> v3: currencies become data", () => {
test("nothing else about the dataset is touched", () => {
const before = v2();
const migrated = migrate(structuredClone(before));
expect(migrated.posts).toEqual(before.posts);
expect(migrated.posts.map(({ folderId, ...post }) => post)).toEqual(before.posts);
expect(migrated.months).toEqual(before.months);
// Sterling's rate arrives later, in 4 -> 5; nothing this step does touches
// the rate table.
@@ -466,7 +466,7 @@ describe("6 -> 7: recurring costs join the dataset", () => {
const v6: any = { ...createSeedDataset("2026-09"), recurring: existing };
v6.settings = { ...v6.settings, schemaVersion: 6 };
expect(migrate(v6).recurring).toEqual(existing);
expect(migrate(v6).recurring).toEqual([{ ...existing[0], order: 3, folderId: null }]);
});
test("NO figure changes: every post in every month folds identically", () => {
@@ -485,3 +485,33 @@ describe("6 -> 7: recurring costs join the dataset", () => {
}
});
});
test("v7 migration preserves records and assigns mixed order", () => {
const data: any = createSeedDataset("2026-09");
data.settings.schemaVersion = 7;
delete data.folders;
data.posts = [
{ ...data.posts[0], id: "later", order: 9, rules: [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 20, currency: "DKK" } } }] },
{ ...data.posts[1], id: "first", order: 2 },
].map(({ folderId, ...post }: any) => post);
data.recurring = [
{ id: "bill-later", name: "Later", order: 8, splits: [{ postId: "first", value: 100, absorbsRemainder: true }] },
{ id: "bill-first", name: "First", order: 1, splits: [{ postId: "later", value: 100, absorbsRemainder: true }] },
];
data.purchases = [{ id: "purchase", splits: [{ postId: "later", value: 100, absorbsRemainder: true }] }];
const before = structuredClone(data);
const migrated = migrate(data);
expect(migrated.folders).toEqual([]);
expect(migrated.posts.map((post) => [post.id, post.order, post.folderId])).toEqual([
["later", 1, null], ["first", 0, null],
]);
expect(migrated.recurring.map((bill) => [bill.id, bill.order, bill.folderId])).toEqual([
["bill-later", 3, null], ["bill-first", 2, null],
]);
expect(new Set([...migrated.posts, ...migrated.recurring].map((item) => item.order)).size).toBe(4);
expect(migrated.posts[0]!.rules).toEqual(before.posts[0].rules);
expect(migrated.recurring.map((bill) => bill.splits)).toEqual(before.recurring.map((bill: any) => bill.splits));
expect(migrated.purchases).toEqual(before.purchases);
});
+21
View File
@@ -193,6 +193,27 @@ const MIGRATIONS: Array<(data: any) => any> = [
settings: { ...data.settings, schemaVersion: 7 },
recurring: Array.isArray(data.recurring) ? data.recurring : [],
}),
// 7 -> 8: posts and recurring bills gain independent locations in a shared
// folder tree. Preserve each type's former display order, with posts first.
// The migration writes no financial data and reads no live default.
(data: any) => {
const posts = Array.isArray(data.posts) ? data.posts : [];
const recurring = Array.isArray(data.recurring) ? data.recurring : [];
const postOrders = new Map(
[...posts].sort((a: any, b: any) => a.order - b.order).map((post: any, index) => [post, index]),
);
const billOrders = new Map(
[...recurring].sort((a: any, b: any) => a.order - b.order).map((bill: any, index) => [bill, posts.length + index]),
);
return {
...data,
settings: { ...data.settings, schemaVersion: 8 },
folders: [],
posts: posts.map((post: any) => ({ ...post, order: postOrders.get(post), folderId: null })),
recurring: recurring.map((bill: any) => ({ ...bill, order: billOrders.get(bill), folderId: null })),
};
},
];
export function migrate(raw: unknown): Dataset {
+1
View File
@@ -181,6 +181,7 @@ describe("reset", () => {
id: "extra",
name: "Extra",
order: 9,
folderId: null,
archived: false,
currency: "DKK",
rules: [],
+1
View File
@@ -65,6 +65,7 @@ function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
id: "r1",
name: "Rent",
order: 0,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -373,6 +373,7 @@ export function RecurringSection() {
void mutate((draft) =>
addRecurringCost(draft, {
name,
folderId: null,
archived: false,
amount: { amount: 0, currency: base },
startDate: currentMonth,