From f1e42d9d77b14693d85d4cfe63e1fd86e2e1e0b3 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:13:45 +0200 Subject: [PATCH] docs: design budget folders and recurring cost placement --- ...ers-and-recurring-cost-placement-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md diff --git a/docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md b/docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md new file mode 100644 index 0000000..e772147 --- /dev/null +++ b/docs/specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md @@ -0,0 +1,123 @@ +# Budget folders and recurring cost placement + +## Intent + +The owner wants to organize a budget in folders and subfolders without making +every recurring bill an envelope. Rent may have its own post, while phone and +internet may share one post; the recurring rules remain individually editable. +The month page should show everyday posts such as Video Games, Events, and +Social cleanly alongside larger commitments. A bill's place in the folder tree +is independent of the post or posts its money uses. + +Success means the owner can choose or create a destination post when adding a +bill, change that destination later, organize posts and bills together in nested +folders, and read the scheduled bill total of a folder for a selected month. +The post fold, two-stage bill confirmation, and purchase ledger remain the +sources of financial truth. + +## Model + +Add `Dataset.folders: BudgetFolder[]`, with each folder carrying `id`, `name`, +`parentId: FolderId | null`, and `order`. Add +`folderId: FolderId | null` to both `Post` and `RecurringCost`. `null` means +Unfiled, a visible virtual root group rather than a stored folder. Existing +records migrate with `folderId: null`; the v7 -> v8 migration adds an empty +folder array and assigns one deterministic sibling order to the existing posts +and bills. A new dataset starts with an empty folder array too. + +Folders may nest to any depth. Sibling folders, posts, and bills share a +display order within their parent. The mutation layer assigns that order when +adding or moving an item and prevents a folder from becoming its own ancestor. +An item has one folder location; a recurring bill can still have splits into +several posts, including posts in other folders. Moving an item changes its +current organization across views, including past months, but never changes a +stored purchase, allocation rule, or computed post figure. + +A folder is never an envelope: it has no currency, allocation rule, rollover, +or purchase split target. Only posts have those behaviors. Bill splits retain +their current `Split[]` format and remainder handling. No occurrence is stored +to support folders. + +## Folder totals + +On the month page, each folder heading shows **Bills due** in the dataset's +base currency. The figure is the sum of the prices of recurring occurrences +whose scheduled slot falls in the displayed month and whose bill is placed in +that folder or a descendant folder. A bill in a different folder contributes +there, even when it charges a post in this folder. Each occurrence contributes +once, so a 28-day bill can contribute twice in a month. An ended bill contributes +only before its end date. An empty folder shows zero; a folder containing only +posts also shows zero bills due. + +For an unconfirmed occurrence, use its current expected amount. For a confirmed +occurrence, use the confirming purchase's actual total, because that is the +historical price paid. The slot month determines where the price appears; +`Purchase.date` continues to determine where the actual charge folds. Convert +each price to base using the same FX and `settings.digits` rules as the existing +money calculations, then round the sum at the boundary. A missing FX rate must +surface as an error, never a fallback. Negative prices remain allowed. + +This heading is a schedule total, not a balance. It does not add post +allocations, remaining balances, or purchase charges to bill prices. Thus a +folder holding both a Rent post and its 8,000 rent bill shows 8,000 in **Bills +due**, not 16,000. Post balances remain visible on the post row. Parent folder +totals include each descendant bill exactly once. + +## Month page and editing + +Replace the flat post section with a tree of collapsible folders. Within an +open folder, posts keep their existing balance figures, meter, post links, and +allocation editing. Recurring bills appear as distinct rows showing their +price, cadence, assigned posts, and next scheduled occurrence (or Ended); an +Edit action opens the bill editor. The folder heading shows its Bills due total for this +month. The Unfiled group holds existing items and any newly created item not +placed in a folder. Collapsed folders still show their total so the first page +stays scannable. Keyboard users can expand and collapse folders, and the mobile +view uses a list rather than squeezing the desktop financial table. + +Keep the due/overdue area as the fast reconciliation queue. It continues to +confirm occurrences into purchases using the existing rules, including +off-schedule payment dates. Bill rows in folders supply context and an editor, +not a second confirmation path or a second balance. + +In the add-bill flow, select an existing destination post, create a dedicated +post (for example Rent), or expand a split editor to assign several posts. +Saving is disabled until the split has a destination. Creating a post and bill +is one store mutation so a failed write leaves neither behind. A newly created +dedicated post starts in the bill's selected folder, but either item can be +moved independently afterward. Editing an existing bill exposes the same split +controls. The bill amount remains a text field using `parseMoneyInput`. + +Settings shows the same hierarchy for creating, renaming, reordering, moving, +and nesting folders and items. It also retains direct bill-rule editing and +post allocation history. Archived posts keep their fold history; ended bills +keep confirmations. The placement controls do not present folders as purchase +or allocation targets. + +## Boundaries and verification + +The domain provides a pure selector for the folder tree and scheduled totals, +using `occurrencesByMonth` rather than a second recurrence walk. It accepts the +displayed month from the caller and reads no clock. It never reimplements the +rollover fold. Store actions validate parent references, moves, cycles, and +split destinations. JSON import validates unique folder IDs, valid parent and +item references, and acyclic ancestry after migration. JSON export includes +the complete hierarchy; whole-dataset import keeps its existing backup gate. + +Tests cover migration from v7, nested totals without double counting, multiple +occurrences in one month, confirmed actual prices and off-schedule dates, bills +assigned to posts in another folder, moves and cycle rejection, and import +validation. UI checks cover choosing or creating a post, editing a bill split, +Unfiled, collapse controls, and desktop/mobile month layouts. Browser captures +check layout and console errors; they do not establish whether the design feels +good in use. + +## Alternatives considered + +- **Labels on a flat list:** simpler storage but no folder/subfolder hierarchy + or useful collapse behavior for the first page. +- **Nested posts as folders:** would make parent items look budgetable and mix + bill prices with envelope balances, obscuring where money is actually counted. +- **One tree containing folders, posts, and bill rules:** selected. It makes + placement explicit while keeping bill assignment and envelope accounting + separate.