feat: place purchases and bills in budget hierarchy

This commit is contained in:
Lucas Winther committed 2026-09-30 13:48:49 +02:00
1 parent b0e8145d2e
commit 85a21c58eb
19 files changed
+281 -70

No files matched your search

+5 -3
View File
@@ -54,9 +54,11 @@ Money is handled in DKK, USD and EUR, with DKK as the base.
Confirmed and in place:
- Nested budget folders organize posts and bills independently of bill split
destinations. Folder Bills due reports scheduled prices for the selected month;
posts alone hold envelope balances.
- Nested budget folders organize posts, bills, and optionally individual
purchases. A single-post bill starts beneath that post but can be moved
independently. Folder Bills due reports scheduled bill prices for the selected
month; posts alone hold envelope balances. A purchase's separate row never
changes which posts its splits charge.
- Posts (envelopes) with monthly allocation, created and archived by the owner.
Posts are archived, never deleted, because purchases reference them.
- A post with no applicable allocation rule still records spending and shows its
+4 -3
View File
@@ -10,17 +10,18 @@ task moved to `AGENTS.md`.
**Current feature worktree state:** budget folders and recurring cost placement
are implemented.
712 tests passing across 55 files. `bunx tsc --noEmit` is clean;
746 tests passing across 59 files. `bunx tsc --noEmit` is clean;
`bun run --bun build` succeeds using the committed seed rates when live-fetch
is unavailable. The runtime override bypasses a broken local Bun shim.
**Schema is at version 8.** v1 -> v2 made allocation rules a dated series;
**Schema is at version 9.** v1 -> v2 made allocation rules a dated series;
v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service
URL that can no longer work; v4 -> v5 adds sterling to an existing dataset;
v5 -> v6 collapses per-currency decimals into one dataset-wide setting; v6 -> v7
adds `Dataset.recurring`; v7 -> v8 adds budget folders and assigns existing
posts and recurring bills a deterministic mixed sibling order under Unfiled.
All seven are behaviour-preserving by construction —
v8 -> v9 adds optional purchase and bill placement fields without moving old rows.
All eight are behaviour-preserving by construction —
not one changes a stored figure — and are tested as such.
A migration applies **on load, in memory**; it is written back on the next
@@ -0,0 +1,9 @@
# Purchase and bill placement in budget folders
The owner can edit a purchase's post splits and also give it an independent row in the budget listing. That row may live in a selected folder or Unfiled. The purchase still appears in the monthly ledger, and its splits alone determine which envelope is charged. The budget row shows only in months where the purchase or an active finance-plan slice contributes. Moving the row changes organization, not the fold.
`Purchase.placement` is optional and carries a folder ID plus a sibling order. An absent placement means the purchase stays only in the monthly ledger. The edit dialog controls this field; the Settings folder tree can subsequently move or reorder a placed purchase. Folder deletion moves its placed purchases to the parent with the other children.
A new recurring cost assigned to one post defaults to a row nested beneath that post. A dedicated post and its bill are nested when created together. A bill split across several posts remains at folder level. An explicit folder choice in the add form takes precedence. `RecurringCost.parentPostId` records the nested display parent, while its splits remain the financial destinations. A nested bill follows the post when the post moves. Settings can move the bill to another post or to a folder without changing its splits or confirmations.
Schema 9 adds these optional fields. The v8 to v9 migration only advances the version; existing purchases and bills retain their locations. JSON import checks placement references, sibling order, and parent post references. Folder Bills due includes nested bills once. Purchase rows do not contribute to Bills due, since the figure is a schedule total.
+27
View File
@@ -122,3 +122,30 @@ test("empty and post-only folders have zero Bills due", () => {
data.posts = [post("rent", "post-only")];
expect(tree(data).roots.map(node => node.kind === "folder" ? node.billsDue : NaN)).toEqual([0, 0]);
});
test("a placed purchase is its own row while its post still carries the charge", () => {
const data = dataset();
data.folders = [folder("housing", null)];
data.posts = [post("rent", "housing")];
data.purchases = [{ id: "paid", date: "2026-09", description: "Deposit", total: { amount: 100, currency: "DKK" }, splitMode: "percent", splits: [{ postId: "rent", value: 100, absorbsRemainder: true }], schedule: null, placement: { folderId: "housing", order: 1 } }];
const housing = tree(data).roots[0]!;
if (housing.kind !== "folder") throw new Error("Missing folder");
expect(housing.children.map(node => node.kind)).toEqual(["post", "purchase"]);
expect(monthView(data, "2026-09").rows[0]!.figures.charges).toBe(100);
expect(tree(data, "2026-10").roots[0]).toMatchObject({ kind: "folder", children: [{ kind: "post" }] });
});
test("a bill nested under its post follows that post into another folder", () => {
const data = dataset();
data.folders = [folder("housing", null), folder("other", null, 1)];
data.posts = [post("rent", "housing")];
data.recurring = [{ ...bill("rent-bill", null), parentPostId: "rent" }];
const first = tree(data).roots[0]!;
if (first.kind !== "folder") throw new Error("Missing folder");
expect(first.billsDue).toBe(8000);
expect(first.children[0]).toMatchObject({ kind: "post", children: [{ kind: "bill" }] });
data.posts[0]!.folderId = "other";
const second = tree(data).roots[1]!;
if (second.kind !== "folder") throw new Error("Missing folder");
expect(second.billsDue).toBe(8000);
});
+36 -17
View File
@@ -1,16 +1,18 @@
import { toBase } from "./fx.ts";
import { roundMoney } from "./money.ts";
import { sliceAmountForMonth } from "./charges.ts";
import {
nextUnconfirmedOccurrenceOnOrAfter,
occurrencesByMonth,
type Occurrence,
} from "./occurrences.ts";
import type { BudgetFolder, Dataset, MonthId, RecurringCost } from "./types.ts";
import type { BudgetFolder, Dataset, MonthId, Purchase, RecurringCost } from "./types.ts";
import type { MonthPostRow } from "./views.ts";
export type BudgetNode =
| { kind: "folder"; folder: BudgetFolder; billsDue: number; children: BudgetNode[] }
| { kind: "post"; row: MonthPostRow }
| { kind: "post"; row: MonthPostRow; children: BudgetNode[] }
| { kind: "purchase"; purchase: Purchase; amount: NonNullable<ReturnType<typeof sliceAmountForMonth>> }
| { kind: "bill"; cost: RecurringCost; occurrences: Occurrence[]; nextOccurrence: Occurrence | null };
export interface BudgetTreeView {
@@ -45,30 +47,29 @@ export function budgetTree(
add(folder.parentId, folder.order, { kind: "folder", folder, billsDue: 0, children: [] });
}
for (const row of rows) {
add(row.post.folderId, row.post.order, { kind: "post", row });
add(row.post.folderId, row.post.order, { kind: "post", row, children: [] });
}
const postBills = new Map<string, BudgetNode[]>();
for (const cost of dataset.recurring) {
add(cost.folderId, cost.order, {
const node: BudgetNode = {
kind: "bill",
cost,
occurrences: slotsByBill.get(cost.id) ?? [],
nextOccurrence: nextUnconfirmedOccurrenceOnOrAfter(dataset, cost.id, monthId),
});
};
if (cost.parentPostId) {
const children = postBills.get(cost.parentPostId) ?? [];
children.push(node);
postBills.set(cost.parentPostId, children);
} else add(cost.folderId, cost.order, node);
}
for (const purchase of dataset.purchases) {
if (!purchase.placement) continue;
const amount = sliceAmountForMonth(purchase, monthId);
if (amount) add(purchase.placement.folderId, purchase.placement.order, { kind: "purchase", purchase, amount });
}
const digits = dataset.settings.digits;
const childrenOf = (parent: string | null): BudgetNode[] =>
(byParent.get(parent) ?? [])
.sort((a, b) => a.order - b.order)
.map(({ node }) => {
if (node.kind === "folder") {
node.children = childrenOf(node.folder.id);
node.billsDue = node.children.reduce((sum, child) =>
roundMoney(sum + (child.kind === "folder" ? child.billsDue : child.kind === "bill" ? billDue(child.occurrences) : 0), digits), 0);
}
return node;
});
const billDue = (occurrences: readonly Occurrence[]): number =>
occurrences.reduce((sum, occurrence) => {
const purchase = occurrence.confirmedBy ? purchases.get(occurrence.confirmedBy) : null;
@@ -78,6 +79,24 @@ export function budgetTree(
const price = purchase?.total ?? occurrence.amount;
return roundMoney(sum + toBase(price, dataset.settings.baseCurrency, dataset.fxRates, digits), digits);
}, 0);
const dueInNode = (node: BudgetNode): number => {
if (node.kind === "folder") return node.billsDue;
if (node.kind === "bill") return billDue(node.occurrences);
if (node.kind === "post") return node.children.reduce((sum, child) => roundMoney(sum + dueInNode(child), digits), 0);
return 0;
};
const childrenOf = (parent: string | null): BudgetNode[] =>
(byParent.get(parent) ?? [])
.sort((a, b) => a.order - b.order)
.map(({ node }) => {
if (node.kind === "folder") {
node.children = childrenOf(node.folder.id);
node.billsDue = node.children.reduce((sum, child) => roundMoney(sum + dueInNode(child), digits), 0);
} else if (node.kind === "post") {
node.children = (postBills.get(node.row.post.id) ?? []).sort((a, b) => a.kind === "bill" && b.kind === "bill" ? a.cost.order - b.cost.order : 0);
}
return node;
});
const rootItems = childrenOf(null);
return {
+1 -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 = 8;
export const SCHEMA_VERSION = 9;
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
+4
View File
@@ -148,6 +148,8 @@ export interface RecurringCost {
name: string;
order: number;
folderId: FolderId | null;
/** When set, the bill is displayed beneath this post and follows its folder. */
parentPostId?: PostId;
/**
* A UI flag ONLY. Projection is governed by `startDate` and `endedFrom`,
* exactly as an archived post still folds — archiving must not retroactively
@@ -176,6 +178,8 @@ export interface RecurringCost {
export interface Purchase {
id: PurchaseId;
/** Optional standalone budget row; splits remain the financial destination. */
placement?: { folderId: FolderId | null; order: number };
date: IsoDate;
description: string;
/** Optional longer context, alongside the short `description` label. */
+15
View File
@@ -118,6 +118,21 @@ 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("round-trips a standalone purchase and a bill nested beneath a post", () => {
const data = populated();
data.folders = [folder("home", null, 3)];
data.purchases[0]!.placement = { folderId: "home", order: 0 };
data.recurring.push({ id: "rent", name: "Rent", order: 0, folderId: null, parentPostId: data.posts[0]!.id, 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 }] });
const restored = parse(data);
expect(restored.purchases[0]!.placement?.folderId).toBe("home");
expect(restored.recurring[0]!.parentPostId).toBe(data.posts[0]!.id);
restored.recurring[0]!.parentPostId = "missing";
expect(() => parse(restored)).toThrow(/parent post/);
restored.recurring[0]!.parentPostId = data.posts[0]!.id;
restored.purchases[0]!.placement!.folderId = "missing";
expect(() => parse(restored)).toThrow(/unknown folder/);
});
test("valid nested tree parses", () => {
const data = populated();
data.folders = [folder("home", null, 0), folder("bills", "home", 0)];
+16 -1
View File
@@ -136,6 +136,7 @@ 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 nestedBillOrders = new Map<string, 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`);
@@ -199,7 +200,21 @@ function requireFolderTree(data: Dataset): void {
if (typeof cost?.name !== "string") {
throw new ImportValidationError("Recurring cost name must be a string");
}
requireLocation(cost?.folderId, cost?.order, `Recurring cost "${cost?.name}"`);
if (cost.parentPostId !== undefined) {
if (!data.posts.some(post => post.id === cost.parentPostId)) throw new ImportValidationError(`Recurring cost "${cost.name}" references unknown parent post`);
if (cost.folderId !== null && !parents.has(cost.folderId)) throw new ImportValidationError(`Recurring cost "${cost.name}" references unknown folder`);
requireOrder(cost.order, `Recurring cost "${cost.name}"`);
const orders = nestedBillOrders.get(cost.parentPostId) ?? new Set<number>();
if (orders.has(cost.order)) throw new ImportValidationError(`Recurring cost "${cost.name}" duplicates nested order ${cost.order}`);
orders.add(cost.order);
nestedBillOrders.set(cost.parentPostId, orders);
} else requireLocation(cost?.folderId, cost?.order, `Recurring cost "${cost?.name}"`);
}
for (const purchase of data.purchases) {
if (purchase.placement !== undefined) {
if (!purchase.placement || typeof purchase.placement !== "object") throw new ImportValidationError(`Purchase "${purchase.description}" has invalid placement`);
requireLocation(purchase.placement.folderId, purchase.placement.order, `Purchase "${purchase.description}"`);
}
}
}
+28 -2
View File
@@ -16,6 +16,29 @@ const billInput = (postId: string, folderId: string | null = null) => ({
});
describe("budget folder placement", () => {
test("editing a purchase can place it in a folder without changing its charge", () => {
const data = createSeedDataset("2026-09");
const folder = actions.addBudgetFolder(data, "Trips", null);
const postId = data.posts[0]!.id;
const purchase = actions.addPurchase(data, { date: "2026-09", description: "Ticket", total: { amount: 50, currency: "DKK" }, splitMode: "percent", splits: [{ postId, value: 100, absorbsRemainder: true }], schedule: null });
actions.placePurchase(data, purchase.id, folder.id);
expect(purchase.placement?.folderId).toBe(folder.id);
expect(purchase.splits[0]!.postId).toBe(postId);
actions.placePurchase(data, purchase.id, null);
expect(purchase.placement?.folderId).toBe(null);
actions.placePurchase(data, purchase.id, undefined);
expect(purchase.placement).toBeUndefined();
});
test("a bill can be nested under a post and later moved out", () => {
const data = createSeedDataset("2026-09");
const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id));
actions.placeRecurringUnderPost(data, bill.id, data.posts[0]!.id);
expect(bill.parentPostId).toBe(data.posts[0]!.id);
actions.placeRecurringUnderPost(data, bill.id, data.posts[1]!.id);
expect(bill.parentPostId).toBe(data.posts[1]!.id);
actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, null, 3);
expect(bill.parentPostId).toBeUndefined();
});
test("removing a folder moves its direct contents to its parent in order", () => {
const data = createSeedDataset("2026-09");
const parent = actions.addBudgetFolder(data, "Parent", null);
@@ -25,10 +48,12 @@ describe("budget folder placement", () => {
const child = actions.addBudgetFolder(data, "Child", removed.id);
const post = actions.addPost(data, "Kept", "DKK", removed.id);
const bill = actions.addRecurringCost(data, billInput(post.id, removed.id));
const purchase = actions.addPurchase(data, { date: "2026-09", description: "Ticket", total: { amount: 20, currency: "DKK" }, splitMode: "percent", splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null });
actions.placePurchase(data, purchase.id, removed.id);
actions.removeBudgetFolder(data, removed.id);
expect(data.folders.some(folder => folder.id === removed.id)).toBe(false);
expect([child.parentId, post.folderId, bill.folderId]).toEqual([parent.id, parent.id, parent.id]);
expect(actions.budgetSiblings(data, parent.id).map(item => item.id)).toEqual([before.id, child.id, post.id, bill.id, after.id]);
expect(actions.budgetSiblings(data, parent.id).map(item => item.id)).toEqual([before.id, child.id, post.id, bill.id, purchase.id, after.id]);
expect(bill.splits[0]!.postId).toBe(post.id);
expect(() => parseDatasetJson(exportDatasetJson(data))).not.toThrow();
});
@@ -609,7 +634,8 @@ describe("recurring costs", () => {
const post = data.posts.find(p => p.id === bill.splits[0]!.postId)!;
expect(post.name).toBe("Rent post");
expect(post.folderId).toBe(folder.id);
expect(bill.folderId).toBe(folder.id);
expect(bill.folderId).toBeNull();
expect(bill.parentPostId).toBe(post.id);
expect(bill.splits).toEqual([{ postId: post.id, value: 100, absorbsRemainder: true }]);
expect(post.rules).toEqual([{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 8000, currency: "DKK" } } }]);
actions.confirmOccurrence(data, bill.id, "2026-09");
+53 -16
View File
@@ -45,30 +45,33 @@ function requirePost(draft: Dataset, postId: PostId): Post {
return post;
}
export type BudgetItemKey = { kind: "folder" | "post" | "recurring"; id: string };
type BudgetItem = BudgetFolder | Post | RecurringCost;
export type BudgetItemKey = { kind: "folder" | "post" | "recurring" | "purchase"; id: string };
type BudgetItem = BudgetFolder | Post | RecurringCost | Purchase;
type BudgetSibling = BudgetItemKey & { order: number };
const orderOf = (item: BudgetItem): number => "placement" in item && item.placement ? item.placement.order : (item as BudgetFolder | Post | RecurringCost).order;
const setOrder = (item: BudgetItem, order: number): void => { if ("placement" in item && item.placement) item.placement.order = order; else (item as BudgetFolder | Post | RecurringCost).order = order; };
function siblingRefs(draft: Dataset, parentId: FolderId | null): Array<{ key: BudgetItemKey; item: BudgetItem }> {
return [
...draft.folders.filter(item => item.parentId === parentId).map(item => ({ key: { kind: "folder" as const, id: item.id }, item })),
...draft.posts.filter(item => item.folderId === parentId).map(item => ({ key: { kind: "post" as const, id: item.id }, item })),
...draft.recurring.filter(item => item.folderId === parentId).map(item => ({ key: { kind: "recurring" as const, id: item.id }, item })),
].sort((a, b) => a.item.order - b.item.order);
...draft.recurring.filter(item => !item.parentPostId && item.folderId === parentId).map(item => ({ key: { kind: "recurring" as const, id: item.id }, item })),
...draft.purchases.filter(item => item.placement?.folderId === parentId).map(item => ({ key: { kind: "purchase" as const, id: item.id }, item })),
].sort((a, b) => orderOf(a.item) - orderOf(b.item));
}
/** A clock-free, FX-free view of the shared order for Settings and placement. */
export function budgetSiblings(draft: Dataset, parentId: FolderId | null): BudgetSibling[] {
return siblingRefs(draft, parentId).map(({ key, item }) => ({ ...key, order: item.order }));
return siblingRefs(draft, parentId).map(({ key, item }) => ({ ...key, order: orderOf(item) }));
}
function nextSiblingOrder(draft: Dataset, parentId: FolderId | null): number {
const siblings = siblingRefs(draft, parentId);
if (!siblings.length) return 0;
const highest = siblings[siblings.length - 1]!.item.order;
const highest = orderOf(siblings[siblings.length - 1]!.item);
const next = highest + 1;
if (Number.isFinite(next) && next > highest) return next;
siblings.forEach(({ item }, order) => { item.order = order; });
siblings.forEach(({ item }, order) => setOrder(item, order));
return siblings.length;
}
@@ -88,11 +91,12 @@ function requireBudgetItem(draft: Dataset, key: BudgetItemKey): BudgetItem {
if (key.kind === "folder") return requireFolder(draft, key.id);
if (key.kind === "post") return requirePost(draft, key.id);
if (key.kind === "recurring") return requireRecurringCost(draft, key.id);
if (key.kind === "purchase") return requirePurchase(draft, key.id);
throw new Error(`Unknown budget item kind: ${String(key.kind)}`);
}
function itemParent(key: BudgetItemKey, item: BudgetItem): FolderId | null {
return key.kind === "folder" ? (item as BudgetFolder).parentId : (item as Post | RecurringCost).folderId;
return key.kind === "folder" ? (item as BudgetFolder).parentId : key.kind === "purchase" ? (item as Purchase).placement!.folderId : (item as Post | RecurringCost).folderId;
}
export function addBudgetFolder(draft: Dataset, name: string, parentId: FolderId | null): BudgetFolder {
@@ -118,11 +122,12 @@ export function removeBudgetFolder(draft: Dataset, id: FolderId): void {
const children = siblingRefs(draft, id);
for (const { key, item } of children) {
if (key.kind === "folder") (item as BudgetFolder).parentId = parent;
else if (key.kind === "purchase") (item as Purchase).placement!.folderId = parent;
else (item as Post | RecurringCost).folderId = parent;
}
draft.folders.splice(draft.folders.indexOf(folder), 1);
siblings.splice(position, 1, ...children);
siblings.forEach(({ item }, order) => { item.order = order; });
siblings.forEach(({ item }, order) => setOrder(item, order));
}
export function placeBudgetItem(draft: Dataset, key: BudgetItemKey, destination: FolderId | null, index: number): void {
@@ -142,10 +147,40 @@ export function placeBudgetItem(draft: Dataset, key: BudgetItemKey, destination:
throw new Error(`Invalid sibling index: ${index}`);
}
if (key.kind === "folder") (item as BudgetFolder).parentId = destination;
else (item as Post | RecurringCost).folderId = destination;
else if (key.kind === "purchase") (item as Purchase).placement!.folderId = destination;
else {
(item as Post | RecurringCost).folderId = destination;
if (key.kind === "recurring") {
const cost = item as RecurringCost;
if (cost.parentPostId) draft.recurring.filter(other => other.parentPostId === cost.parentPostId && other.id !== cost.id).sort((a, b) => a.order - b.order).forEach((other, position) => { other.order = position; });
delete cost.parentPostId;
}
}
destinationItems.splice(index, 0, { key, item });
if (source !== destination) sourceItems.forEach((entry, order) => { entry.item.order = order; });
destinationItems.forEach((entry, order) => { entry.item.order = order; });
if (source !== destination) sourceItems.forEach((entry, order) => setOrder(entry.item, order));
destinationItems.forEach((entry, order) => setOrder(entry.item, order));
}
export function placePurchase(draft: Dataset, purchaseId: PurchaseId, folderId: FolderId | null | undefined): void {
const purchase = requirePurchase(draft, purchaseId);
if (folderId !== undefined && folderId !== null) requireFolder(draft, folderId);
if (purchase.placement) {
siblingRefs(draft, purchase.placement.folderId).filter(entry => !(entry.key.kind === "purchase" && entry.key.id === purchaseId)).forEach(({ item }, order) => setOrder(item, order));
}
delete purchase.placement;
if (folderId === undefined) delete purchase.placement;
else purchase.placement = { folderId, order: nextSiblingOrder(draft, folderId) };
}
export function placeRecurringUnderPost(draft: Dataset, recurringId: RecurringCostId, postId: PostId): void {
requirePost(draft, postId);
const cost = requireRecurringCost(draft, recurringId);
const formerPostId = cost.parentPostId;
if (!cost.parentPostId) siblingRefs(draft, cost.folderId).filter(entry => !(entry.key.kind === "recurring" && entry.key.id === recurringId)).forEach(({ item }, order) => setOrder(item, order));
if (formerPostId) draft.recurring.filter(other => other.parentPostId === formerPostId && other.id !== recurringId).sort((a, b) => a.order - b.order).forEach((other, index) => { other.order = index; });
cost.folderId = null;
cost.parentPostId = postId;
cost.order = draft.recurring.filter(other => other.parentPostId === postId && other.id !== recurringId).length;
}
function requirePurchase(draft: Dataset, purchaseId: PurchaseId): Purchase {
@@ -592,10 +627,11 @@ function requireStartDateGranularity(recurrence: Recurrence, startDate: IsoDate)
export function addRecurringCost(
draft: Dataset,
cost: Omit<RecurringCost, "id" | "order">,
cost: Omit<RecurringCost, "id" | "order" | "parentPostId">,
): RecurringCost {
const recurrence = requireRecurrence(cost.recurrence);
requireStartDateGranularity(recurrence, cost.startDate);
if ("parentPostId" in cost) throw new Error("Use placeRecurringUnderPost to nest a recurring cost");
if (cost.folderId !== null) requireFolder(draft, cost.folderId);
requireRecurringSplitDestinations(draft, cost.splits);
const created: RecurringCost = {
@@ -613,7 +649,7 @@ export function addRecurringCost(
/** Creates the dedicated envelope and its bill in one store mutation. */
export function addRecurringCostWithPost(
draft: Dataset,
cost: Omit<RecurringCost, "id" | "order" | "splits" | "splitMode">,
cost: Omit<RecurringCost, "id" | "order" | "splits" | "splitMode" | "parentPostId">,
newPostName: string,
): RecurringCost {
const name = newPostName.trim();
@@ -626,6 +662,7 @@ export function addRecurringCostWithPost(
splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
placeRecurringUnderPost(draft, bill.id, post.id);
const monthly = recurrence.kind === "everyNMonths"
? bill.amount.amount / recurrence.n
: recurrence.kind === "everyNWeeks"
@@ -651,10 +688,10 @@ export function addRecurringCostWithPost(
export function updateRecurringCost(
draft: Dataset,
id: RecurringCostId,
changes: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom" | "folderId" | "order">>,
changes: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom" | "folderId" | "order" | "parentPostId">>,
): void {
const cost = requireRecurringCost(draft, id);
if ("folderId" in changes || "order" in changes) throw new Error("Use placeBudgetItem to move a recurring cost");
if ("folderId" in changes || "order" in changes || "parentPostId" in changes) throw new Error("Use placement actions to move a recurring cost");
const resolved: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom" | "folderId" | "order">> = { ...changes };
if (changes.recurrence) {
+11
View File
@@ -515,3 +515,14 @@ test("v7 migration preserves records and assigns mixed order", () => {
expect(migrated.recurring.map((bill) => bill.splits)).toEqual(before.recurring.map((bill: any) => bill.splits));
expect(migrated.purchases).toEqual(before.purchases);
});
test("v8 migration preserves existing purchase and bill locations", () => {
const data = createSeedDataset("2026-09");
data.settings.schemaVersion = 8;
const before = structuredClone(data);
const migrated = migrate(data);
expect(migrated.settings.schemaVersion).toBe(9);
expect(migrated.posts).toEqual(before.posts);
expect(migrated.purchases).toEqual(before.purchases);
expect(migrated.recurring).toEqual(before.recurring);
});
+2
View File
@@ -214,6 +214,8 @@ const MIGRATIONS: Array<(data: any) => any> = [
recurring: recurring.map((bill: any) => ({ ...bill, order: billOrders.get(bill), folderId: null })),
};
},
// 8 -> 9: placement fields are optional, so existing rows stay where they are.
(data: any) => ({ ...data, settings: { ...data.settings, schemaVersion: 9 } }),
];
export function migrate(raw: unknown): Dataset {
+7 -5
View File
@@ -7,6 +7,7 @@ import type { MonthPostRow } from "../../domain/views.ts";
import { useDataset } from "../hooks/useDataset.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { DesktopPostBody, MobilePostRow } from "./PostTable.tsx";
import { PurchaseDialog } from "./PurchaseDialog.tsx";
function cadence(rule: Recurrence) {
const unit = rule.kind === "everyNMonths" ? "month" : rule.kind === "everyNWeeks" ? "week" : "day";
@@ -32,18 +33,19 @@ export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId;
<div className="flex flex-wrap items-baseline justify-between gap-2"><span className="font-medium">{node.cost.name} <span className="ml-2 font-money font-normal">{fmt.money(node.cost.amount.amount, node.cost.amount.currency)}</span></span><Link className="text-xs underline underline-offset-2 hover:text-budget-accent" to={`/settings#bill-${node.cost.id}`}>Edit</Link></div>
<p className="text-xs text-budget-ink-muted">{cadence(node.cost.recurrence)} · Assigned to {node.cost.splits.map(split => dataset.posts.find(post => post.id === split.postId)?.name).join(", ")} · {node.nextOccurrence ? `Next ${node.nextOccurrence.date}` : "Ended"}</p>
</div>;
const purchaseContent = (node: Extract<BudgetNode, { kind: "purchase" }>) => <div className="flex flex-wrap items-center justify-between gap-2 py-3 text-sm"><span>{node.purchase.description} <span className="ml-2 font-money">{fmt.money(node.amount.amount, node.amount.currency)}</span></span><PurchaseDialog monthId={monthId} purchase={node.purchase} trigger={<button type="button" className="text-xs underline underline-offset-2 hover:text-budget-accent">Edit</button>} /></div>;
const render = (nodes: BudgetNode[], mobile: boolean, depth = 0): ReactNode[] => nodes.flatMap(node => {
const indent = Math.min(depth, 4) * 16;
const id = node.kind === "folder" ? node.folder.id : node.kind === "post" ? node.row.post.id : node.cost.id;
if (node.kind === "post") return [mobile ? <MobilePostRow key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} /> : <DesktopPostBody key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} />];
const name = node.kind === "folder" ? node.folder.name : node.cost.name;
const content = node.kind === "folder" ? folderHeading(node) : billContent(node);
const id = node.kind === "folder" ? node.folder.id : node.kind === "post" ? node.row.post.id : node.kind === "purchase" ? node.purchase.id : node.cost.id;
if (node.kind === "post") return [mobile ? <MobilePostRow key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} /> : <DesktopPostBody key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} />, ...render(node.children, mobile, depth + 1)];
const name = node.kind === "folder" ? node.folder.name : node.kind === "purchase" ? node.purchase.description : node.cost.name;
const content = node.kind === "folder" ? folderHeading(node) : node.kind === "purchase" ? purchaseContent(node) : billContent(node);
const style = { paddingInlineStart: `${indent}px` };
const item = mobile ? <li key={id} data-budget-item={id} data-budget-name={name} style={style}>{content}</li> : <tbody key={id} data-budget-item={id} data-budget-name={name}><tr><td colSpan={6} className={node.kind === "folder" ? "border-b border-budget-rule pt-3" : "border-b border-budget-rule"} style={style}>{content}</td></tr></tbody>;
return [item, ...(node.kind === "folder" && expanded.has(id) ? render(node.children, mobile, depth + 1) : [])];
});
return <>
<div className="hidden overflow-x-auto sm:block"><table className="w-full text-sm"><thead className="text-left"><tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">{["Post / bill", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => <th key={label} className={`py-2 font-medium ${index ? "pl-6 text-right" : ""}`}>{label}</th>)}</tr></thead>
<div className="hidden overflow-x-auto sm:block"><table className="w-full text-sm"><thead className="text-left"><tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">{["Budget item", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => <th key={label} className={`py-2 font-medium ${index ? "pl-6 text-right" : ""}`}>{label}</th>)}</tr></thead>
{render(tree.roots, false)}
{tree.unfiled.length > 0 && <tbody><tr><th colSpan={6} className="border-b border-budget-rule pb-2 pt-5 text-left font-medium">Unfiled</th></tr></tbody>}{render(tree.unfiled, false)}
</table></div>
+25 -6
View File
@@ -31,7 +31,7 @@ import {
import { useDataset } from "../hooks/useDataset.ts";
import { parseMoneyInput } from "../moneyInput.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
import { addPurchase, cancelScheduleFrom, placePurchase, updatePurchase } from "../../store/actions.ts";
import type { MonthId, Purchase } from "../../domain/types.ts";
interface Props {
@@ -47,9 +47,13 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
const activePosts = dataset.posts
.filter((p) => !p.archived)
.sort((a, b) => a.order - b.order);
const editablePosts = dataset.posts
.filter((p) => !p.archived || purchase?.splits.some(split => split.postId === p.id))
.sort((a, b) => a.order - b.order);
const [open, setOpen] = useState(false);
const [detachBill, setDetachBill] = useState(false);
const [placement, setPlacement] = useState(() => purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
const [draft, setDraft] = useState<PurchaseDraft>(() =>
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -70,6 +74,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
function reset() {
setDetachBill(false);
setPlacement(purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
setDraft(
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -88,10 +93,14 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
const created = bulkMode ? toPurchases(bulk) : null;
void mutate((data) => {
if (created) for (const p of created) addPurchase(data, p);
else if (purchase) updatePurchase(data, purchase.id, {
...toPurchase(draft),
...(detachBill ? { source: undefined } : {}),
});
else if (purchase) {
updatePurchase(data, purchase.id, {
...toPurchase(draft),
...(detachBill ? { source: undefined } : {}),
});
const destination = placement === "__ledger" ? undefined : placement === "__unfiled" ? null : placement;
if (destination !== purchase.placement?.folderId || (destination === undefined) !== (purchase.placement === undefined)) placePurchase(data, purchase.id, destination);
}
else addPurchase(data, toPurchase(draft));
}).then((saved) => {
if (!saved) return;
@@ -304,7 +313,17 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
/>
</div>
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
<SplitEditor draft={draft} posts={purchase ? editablePosts : activePosts} onChange={setDraft} />
{purchase && <div className="space-y-1">
<Label htmlFor="purchase-placement">Budget listing</Label>
<NativeSelect id="purchase-placement" value={placement} onChange={event => setPlacement(event.target.value)}>
<option value="__ledger">Monthly purchases only</option>
<option value="__unfiled">Own row in Unfiled</option>
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>Own row in {folder.name}</option>)}
</NativeSelect>
<p className="text-xs text-budget-ink-muted">Post splits above determine which balances this purchase charges.</p>
</div>}
{purchase?.source && (
<label className="flex items-start gap-2 text-sm">
@@ -63,6 +63,16 @@ test("post and bill move independently", async () => setup(async (container, dat
const rows = Array.from(container.querySelectorAll("[data-budget-item]"));
expect(rows.findIndex(row => row.getAttribute("data-budget-item") === post.id)).toBeLessThan(rows.findIndex(row => row.getAttribute("data-budget-item") === utilities.id));
}));
test("a bill can move beneath a post and follows that post's folder", async () => setup(async (container, data) => {
const post = data.posts[0]!, bill = data.recurring[0]!;
await act(async () => { select(container, "Folder for Phone", `post:${post.id}`); });
expect(store.get().recurring[0]!.parentPostId).toBe(post.id);
expect(store.get().recurring[0]!.folderId).toBeNull();
await act(async () => { select(container, `Folder for ${post.name}`, data.folders[0]!.id); });
expect(store.get().posts[0]!.folderId).toBe(data.folders[0]!.id);
expect(store.get().recurring[0]!.parentPostId).toBe(post.id);
expect(container.querySelector(`[data-budget-item="${post.id}"] + div [data-budget-item="${bill.id}"]`)).not.toBeNull();
}));
test("remove folder moves nested items into its parent", async () => setup(async (container, data) => {
const housing = data.folders[0]!, utilities = data.folders[1]!;
await act(async () => { (container.querySelector('button[aria-label="Remove folder Utilities"]') as HTMLButtonElement).click(); });
+21 -15
View File
@@ -5,7 +5,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Section } from "../../components/Section.tsx";
import { useDataset } from "../../hooks/useDataset.ts";
import { useMutate } from "../../hooks/useMutate.ts";
import { addBudgetFolder, budgetSiblings, placeBudgetItem, removeBudgetFolder, renameBudgetFolder, type BudgetItemKey } from "../../../store/actions.ts";
import { addBudgetFolder, budgetSiblings, placeBudgetItem, placeRecurringUnderPost, removeBudgetFolder, renameBudgetFolder, type BudgetItemKey } from "../../../store/actions.ts";
export function BudgetFoldersSection() {
const dataset = useDataset();
@@ -45,49 +45,55 @@ export function BudgetFoldersSection() {
}
return true;
}
function children(parentId: string | null): ReactNode {
const siblings = budgetSiblings(dataset, parentId);
function children(parentId: string | null, parentPostId?: string): ReactNode {
const siblings = parentPostId ? dataset.recurring.filter(cost => cost.parentPostId === parentPostId).sort((a, b) => a.order - b.order).map(cost => ({ kind: "recurring" as const, id: cost.id, order: cost.order })) : budgetSiblings(dataset, parentId);
return <ul className="space-y-2">{siblings.map((key, index) => {
const item = key.kind === "folder" ? dataset.folders.find(item => item.id === key.id)! : key.kind === "post" ? dataset.posts.find(item => item.id === key.id)! : dataset.recurring.find(item => item.id === key.id)!;
const item = key.kind === "folder" ? dataset.folders.find(item => item.id === key.id)! : key.kind === "post" ? dataset.posts.find(item => item.id === key.id)! : key.kind === "purchase" ? dataset.purchases.find(item => item.id === key.id)! : dataset.recurring.find(item => item.id === key.id)!;
const name = "description" in item ? item.description : item.name;
return <li key={`${key.kind}-${key.id}`}>
<div data-budget-item={key.id} className="flex flex-wrap items-center gap-2 border-b border-budget-rule py-2">
<div className="min-w-0 flex-1 basis-40">
{key.kind === "folder" ? <Input className="h-8 w-full max-w-64" aria-label={`Name for ${item.name}`} value={folderNames[key.id] ?? item.name} onInput={event => {
{key.kind === "folder" ? <Input className="h-8 w-full max-w-64" aria-label={`Name for ${name}`} value={folderNames[key.id] ?? name} onInput={event => {
const typed = event.currentTarget.value;
setFolderNames(names => ({ ...names, [key.id]: typed }));
}} onBlur={() => saveFolderName(key.id, item.name)} onKeyDown={event => {
}} onBlur={() => saveFolderName(key.id, name)} onKeyDown={event => {
if (event.key === "Enter") {
event.preventDefault();
saveFolderName(key.id, item.name);
saveFolderName(key.id, name);
}
}} /> : <span className="break-words text-sm">{item.name}</span>}
<span className="mt-1 block text-xs text-budget-ink-muted">{key.kind === "folder" ? "Folder" : key.kind === "post" ? "Post" : "Bill"}</span>
}} /> : <span className="break-words text-sm">{name}</span>}
<span className="mt-1 block text-xs text-budget-ink-muted">{key.kind === "folder" ? "Folder" : key.kind === "post" ? "Post" : key.kind === "purchase" ? "Purchase" : "Bill"}</span>
</div>
<NativeSelect className="h-8 w-full sm:w-56 text-xs md:text-xs" aria-label={`Folder for ${item.name}`} value={parentId ?? ""} onChange={event => {
<NativeSelect className="h-8 w-full sm:w-56 text-xs md:text-xs" aria-label={`Folder for ${name}`} value={parentPostId ? `post:${parentPostId}` : parentId ?? ""} onChange={event => {
const destination = event.target.value || null;
void mutate(draft => placeBudgetItem(draft, key, destination, budgetSiblings(draft, destination).filter(sibling => !(sibling.kind === key.kind && sibling.id === key.id)).length));
void mutate(draft => {
if (key.kind === "recurring" && destination?.startsWith("post:")) placeRecurringUnderPost(draft, key.id, destination.slice(5));
else placeBudgetItem(draft, key, destination, budgetSiblings(draft, destination).filter(sibling => !(sibling.kind === key.kind && sibling.id === key.id)).length);
});
}}>
<option value="">Unfiled</option>
{folders.filter(folder => allowedDestination(key, folder.id)).map(folder => <option key={folder.id} value={folder.id}>{folderPath(folder.id)}</option>)}
{key.kind === "recurring" && dataset.posts.map(post => <option key={post.id} value={`post:${post.id}`}>Under {post.name}</option>)}
</NativeSelect>
<div className="flex gap-1">
<Button size="xs" variant="ghost" aria-label={`Move ${item.name} up`} disabled={index === 0} onClick={() => void mutate(draft => {
<Button size="xs" variant="ghost" aria-label={`Move ${name} up`} disabled={index === 0 || !!parentPostId} onClick={() => void mutate(draft => {
const position = budgetSiblings(draft, parentId).findIndex(sibling => sibling.kind === key.kind && sibling.id === key.id);
placeBudgetItem(draft, key, parentId, position - 1);
})}>up</Button>
<Button size="xs" variant="ghost" aria-label={`Move ${item.name} down`} disabled={index === siblings.length - 1} onClick={() => void mutate(draft => {
<Button size="xs" variant="ghost" aria-label={`Move ${name} down`} disabled={index === siblings.length - 1 || !!parentPostId} onClick={() => void mutate(draft => {
const position = budgetSiblings(draft, parentId).findIndex(sibling => sibling.kind === key.kind && sibling.id === key.id);
placeBudgetItem(draft, key, parentId, position + 1);
})}>down</Button>
{key.kind === "folder" && <Button size="xs" variant="ghost" aria-label={`Remove folder ${item.name}`} title="Move contents to parent and remove folder" onClick={() => void mutate(draft => removeBudgetFolder(draft, key.id))}>remove</Button>}
{key.kind === "folder" && <Button size="xs" variant="ghost" aria-label={`Remove folder ${name}`} title="Move contents to parent and remove folder" onClick={() => void mutate(draft => removeBudgetFolder(draft, key.id))}>remove</Button>}
</div>
</div>
{key.kind === "folder" && <div className="ml-3 border-l border-budget-rule pl-3 sm:ml-5 sm:pl-5">{children(key.id)}</div>}
{key.kind === "post" && dataset.recurring.some(cost => cost.parentPostId === key.id) && <div className="ml-3 border-l border-budget-rule pl-3 sm:ml-5 sm:pl-5">{children(parentId, key.id)}</div>}
</li>;
})}</ul>;
}
return <div id="budget-folders" className="scroll-mt-5"><Section title="Budget folders" hint="Organize folders, posts, and bills together. Moving a bill keeps its destination posts and all payment history.">
return <div id="budget-folders" className="scroll-mt-5"><Section title="Budget folders" hint="Organize folders, posts, bills, and placed purchases together. Moving a bill or purchase leaves its post charges unchanged.">
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
<h3 className="text-sm font-medium">Top level · Unfiled</h3>
{children(null)}
@@ -38,6 +38,7 @@ test("existing post destination requires a selection", async () => {
await act(async () => { add.click(); });
const saved = data.recurring[0]!;
expect(saved.splits[0]!.postId).toBe(chosen.id);
expect(saved.parentPostId).toBe(chosen.id);
expect(saved.amount).toEqual({ amount: 30, currency: "USD" });
} finally {
act(() => root.unmount()); container.remove();
@@ -72,6 +73,7 @@ test("new bill defaults to a same-name post and accepts schedule controls", asyn
const bill = data.recurring[0]!;
const post = data.posts.find(post => post.id === bill.splits[0]!.postId)!;
expect(post.name).toBe("Insurance");
expect(bill.parentPostId).toBe(post.id);
expect(bill.recurrence).toEqual({ kind: "everyNMonths", n: 12 });
expect(bill.startDate).toHaveLength(10);
expect(post.rules[0]!.rule).toEqual({ kind: "fixed", amount: { amount: 100, currency: "DKK" } });
+5 -1
View File
@@ -7,6 +7,7 @@ import { useMutate } from "../../hooks/useMutate.ts";
import {
addRecurringCost,
addRecurringCostWithPost,
placeRecurringUnderPost,
endRecurringCost,
restoreRecurringCost,
setRecurringCostEndedFrom,
@@ -478,7 +479,10 @@ export function RecurringSection() {
anchoring,
};
if (selectedKind === "dedicated") addRecurringCostWithPost(draft, input, dedicatedName);
else addRecurringCost(draft, { ...input, splitMode: selectedMode, splits: selectedSplits });
else {
const bill = addRecurringCost(draft, { ...input, splitMode: selectedMode, splits: selectedSplits });
if (selectedFolder === null && selectedSplits.length === 1) placeRecurringUnderPost(draft, bill.id, selectedSplits[0]!.postId);
}
},
).then((saved) => {
if (saved) { setNewName(""); setNewPostName(""); setNewAmount(`0 ${base}`); setNewSelection({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); }