diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 57b9355..9a2c58f 100755 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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 diff --git a/docs/TODO.md b/docs/TODO.md index 45b9c58..7f935b5 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/docs/specs/2026-09-30-purchase-and-bill-budget-placement.md b/docs/specs/2026-09-30-purchase-and-bill-budget-placement.md new file mode 100644 index 0000000..39080b0 --- /dev/null +++ b/docs/specs/2026-09-30-purchase-and-bill-budget-placement.md @@ -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. diff --git a/src/domain/budgetTree.test.ts b/src/domain/budgetTree.test.ts index bdd9a19..691aaf8 100644 --- a/src/domain/budgetTree.test.ts +++ b/src/domain/budgetTree.test.ts @@ -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); +}); diff --git a/src/domain/budgetTree.ts b/src/domain/budgetTree.ts index b0f424b..f82258a 100644 --- a/src/domain/budgetTree.ts +++ b/src/domain/budgetTree.ts @@ -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> } | { 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(); 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 { diff --git a/src/domain/seed.ts b/src/domain/seed.ts index 9546075..6f57f7b 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -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; diff --git a/src/domain/types.ts b/src/domain/types.ts index 2eee7d1..063ef15 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -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. */ diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 38b9097..d8b675b 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -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)]; diff --git a/src/export/json.ts b/src/export/json.ts index 586c788..4956c46 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -136,6 +136,7 @@ function requireFolderTree(data: Dataset): void { const folders = requireArray(data as unknown as Record, "folders"); const parents = new Map(); const siblingOrders = new Map>(); + const nestedBillOrders = new Map>(); 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(); + 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}"`); + } } } diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 7838ab3..26ee444 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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"); diff --git a/src/store/actions.ts b/src/store/actions.ts index 430d129..c01fea1 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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, + cost: Omit, ): 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, + cost: Omit, 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>, + changes: Partial>, ): 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> = { ...changes }; if (changes.recurrence) { diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 69b5f3a..502604f 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -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); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index f58f54a..91c75f1 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -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 { diff --git a/src/ui/components/BudgetTree.tsx b/src/ui/components/BudgetTree.tsx index 0a0144b..2ce1241 100644 --- a/src/ui/components/BudgetTree.tsx +++ b/src/ui/components/BudgetTree.tsx @@ -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;
{node.cost.name} {fmt.money(node.cost.amount.amount, node.cost.amount.currency)}Edit

{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"}

; + const purchaseContent = (node: Extract) =>
{node.purchase.description} {fmt.money(node.amount.amount, node.amount.currency)}Edit} />
; 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 ? : ]; - 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 ? : , ...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 ?
  • {content}
  • : {content}; return [item, ...(node.kind === "folder" && expanded.has(id) ? render(node.children, mobile, depth + 1) : [])]; }); return <> -
    {["Post / bill", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => )} +
    {label}
    {["Budget item", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => )} {render(tree.roots, false)} {tree.unfiled.length > 0 && }{render(tree.unfiled, false)}
    {label}
    Unfiled
    diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index 884e436..b3e31c5 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -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(() => 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) { /> - + + + {purchase &&
    + + setPlacement(event.target.value)}> + + + {dataset.folders.map(folder => )} + +

    Post splits above determine which balances this purchase charges.

    +
    } {purchase?.source && (