From dfbe584874747a9a9ed5384282b61d3f7468106f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 30 Sep 2026 16:35:44 +0200 Subject: [PATCH] fix: keep unassigned purchases visible in budget --- docs/ARCHITECTURE.md | 5 ++++- src/domain/budgetTree.test.ts | 13 +++++++++++++ src/domain/budgetTree.ts | 9 +++++++-- src/store/actions.test.ts | 12 ++++++++++++ src/store/actions.ts | 10 ++++++++++ src/ui/components/BudgetTree.test.tsx | 23 ++++++++++++++++++++++- src/ui/components/BudgetTree.tsx | 11 +++++++++-- src/ui/components/PurchaseDialog.tsx | 5 +++-- 8 files changed, 80 insertions(+), 8 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb792e3..6021800 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -160,7 +160,10 @@ test — and charges a fixed "2000 to Video Games" in full on every slice. A purchase with no post has an empty `splits` array. Its dated amount counts in monthly, yearly and summary Spent totals, but charges no envelope and changes no rollover balance. Recurring bills still require a destination post. JSON import -and ODS export preserve these unassigned purchases. +and ODS export preserve these unassigned purchases. In the month Budget tree, +they appear in the permanent, derived Unassigned section for each month their +purchase or finance plan touches. Creating a post from one assigns its full +amount to the new post without changing the purchase total or schedule. A post with no applicable allocation rule has no overspend status, even if its balance is negative. The balance remains visible and carries normally. An diff --git a/src/domain/budgetTree.test.ts b/src/domain/budgetTree.test.ts index 691aaf8..6dea63a 100644 --- a/src/domain/budgetTree.test.ts +++ b/src/domain/budgetTree.test.ts @@ -135,6 +135,19 @@ test("a placed purchase is its own row while its post still carries the charge", expect(tree(data, "2026-10").roots[0]).toMatchObject({ kind: "folder", children: [{ kind: "post" }] }); }); +test("unassigned purchases appear in one Budget group for their slice month", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.purchases = [ + { id: "loose", date: "2026-09", description: "Loose", total: { amount: 25, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null }, + { id: "placed", date: "2026-09", description: "Placed", total: { amount: 40, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null, placement: { folderId: "housing", order: 0 } }, + ]; + const september = tree(data); + expect(september.unassigned.map(node => node.kind === "purchase" ? node.purchase.id : "other")).toEqual(["loose", "placed"]); + expect(september.roots[0]).toMatchObject({ kind: "folder", children: [] }); + expect(tree(data, "2026-10").unassigned).toEqual([]); +}); + 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)]; diff --git a/src/domain/budgetTree.ts b/src/domain/budgetTree.ts index f82258a..6b6c567 100644 --- a/src/domain/budgetTree.ts +++ b/src/domain/budgetTree.ts @@ -18,6 +18,7 @@ export type BudgetNode = export interface BudgetTreeView { roots: BudgetNode[]; unfiled: BudgetNode[]; + unassigned: BudgetNode[]; } /** Pure month-specific placement and scheduled bill prices. */ @@ -63,10 +64,13 @@ export function budgetTree( postBills.set(cost.parentPostId, children); } else add(cost.folderId, cost.order, node); } + const unassigned: BudgetNode[] = []; 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 }); + if (!amount) continue; + const node: BudgetNode = { kind: "purchase", purchase, amount }; + if (purchase.splits.length === 0) unassigned.push(node); + else if (purchase.placement) add(purchase.placement.folderId, purchase.placement.order, node); } const digits = dataset.settings.digits; @@ -102,5 +106,6 @@ export function budgetTree( return { roots: rootItems.filter(node => node.kind === "folder"), unfiled: rootItems.filter(node => node.kind !== "folder"), + unassigned, }; } diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 26ee444..3ad42dc 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -16,6 +16,18 @@ const billInput = (postId: string, folderId: string | null = null) => ({ }); describe("budget folder placement", () => { + test("an unassigned purchase can create and charge its own post", () => { + const data = createSeedDataset("2026-09"); + const folder = actions.addBudgetFolder(data, "Trips", null); + data.fxRates.push({ currency: "EUR", baseUnitsPerOne: 7.5, updatedAt: "2026-09-01", source: "manual" }); + const purchase = actions.addPurchase(data, { date: "2026-09", description: "Train ticket", total: { amount: 50, currency: "EUR" }, splitMode: "percent", splits: [], schedule: null }); + actions.placePurchase(data, purchase.id, folder.id); + const post = actions.createPostForPurchase(data, purchase.id); + expect(post).toMatchObject({ name: "Train ticket", currency: "EUR", folderId: folder.id }); + expect(purchase.splits).toEqual([{ postId: post.id, value: 100, absorbsRemainder: true }]); + expect(monthView(data, "2026-09").looseCharges).toBe(0); + expect(() => actions.createPostForPurchase(data, purchase.id)).toThrow(/already assigned/i); + }); 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); diff --git a/src/store/actions.ts b/src/store/actions.ts index c01fea1..b3cdaa7 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -292,6 +292,16 @@ export function addPost(draft: Dataset, name: string, currency: Currency, folder return post; } +/** Give a loose purchase an envelope without changing its amount or schedule. */ +export function createPostForPurchase(draft: Dataset, purchaseId: PurchaseId): Post { + const purchase = requirePurchase(draft, purchaseId); + if (purchase.splits.length > 0) throw new Error("Purchase is already assigned to a post"); + const post = addPost(draft, purchase.description.trim() || "Purchase", purchase.total.currency, purchase.placement?.folderId ?? null); + purchase.splits = [{ postId: post.id, value: 100, absorbsRemainder: true }]; + purchase.splitMode = "percent"; + return post; +} + export function updatePost( draft: Dataset, postId: PostId, diff --git a/src/ui/components/BudgetTree.test.tsx b/src/ui/components/BudgetTree.test.tsx index 8700314..14d4404 100644 --- a/src/ui/components/BudgetTree.test.tsx +++ b/src/ui/components/BudgetTree.test.tsx @@ -6,7 +6,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter } from "react-router"; import { createSeedDataset } from "../../domain/seed.ts"; -import { addBudgetFolder, addRecurringCost, confirmOccurrence, placeRecurringUnderPost } from "../../store/actions.ts"; +import { addBudgetFolder, addPurchase, addRecurringCost, confirmOccurrence, placeRecurringUnderPost } from "../../store/actions.ts"; import { monthView } from "../../domain/views.ts"; import { store } from "../../store/index.ts"; import { PostTableLegend } from "./PostTable.tsx"; @@ -81,3 +81,24 @@ test("bills beneath a post are hidden until its post is expanded", () => { act(() => root.unmount()); container.remove(); Object.assign(store, original); } }); + +test("Unassigned is a permanent Budget section with a create-post action", () => { + const data = createSeedDataset("2026-09"); + const purchase = addPurchase(data, { date: "2026-09", description: "Train ticket", total: { amount: 50, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null }); + const original = { get: store.get, subscribe: store.subscribe }; + store.get = () => data; + store.subscribe = () => () => {}; + const container = document.createElement("div"); document.body.append(container); + const root = createRoot(container); + try { + act(() => root.render()); + for (const scope of [container.querySelector("table")!, container.querySelector("ul")!]) { + expect(scope.textContent).toContain("Unassigned"); + const row = scope.querySelector(`[data-budget-item="${purchase.id}"]`)!; + expect(row.textContent).toContain("Train ticket"); + expect(row.querySelector("button[aria-label='Create post for Train ticket']")).not.toBeNull(); + } + } finally { + act(() => root.unmount()); container.remove(); Object.assign(store, original); + } +}); diff --git a/src/ui/components/BudgetTree.tsx b/src/ui/components/BudgetTree.tsx index 6898cf0..fad7cb2 100644 --- a/src/ui/components/BudgetTree.tsx +++ b/src/ui/components/BudgetTree.tsx @@ -2,10 +2,12 @@ import { useState, type ReactNode } from "react"; import { Link } from "react-router"; import { ChevronDown, ChevronRight } from "lucide-react"; import { budgetTree, type BudgetNode } from "../../domain/budgetTree.ts"; +import { createPostForPurchase } from "../../store/actions.ts"; import type { MonthId, Recurrence } from "../../domain/types.ts"; import type { MonthPostRow } from "../../domain/views.ts"; import { useDataset } from "../hooks/useDataset.ts"; import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; +import { useMutate } from "../hooks/useMutate.ts"; import { DesktopPostBody, MobilePostRow } from "./PostTable.tsx"; import { PurchaseDialog } from "./PurchaseDialog.tsx"; @@ -18,6 +20,7 @@ function cadence(rule: Recurrence) { export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId; rows: MonthPostRow[]; onChangeRule?: (postId: string) => void }) { const dataset = useDataset(); const fmt = useMoneyFormat(); + const { mutate, error } = useMutate(); const tree = budgetTree(dataset, monthId, rows); const [expanded, setExpanded] = useState>(() => new Set()); const toggle = (id: string) => setExpanded(previous => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); @@ -33,7 +36,7 @@ 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 purchaseContent = (node: Extract) =>
{node.purchase.description} {fmt.money(node.amount.amount, node.amount.currency)}{node.purchase.splits.length === 0 && }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.kind === "purchase" ? node.purchase.id : node.cost.id; @@ -48,10 +51,14 @@ export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId; return [item, ...(node.kind === "folder" && expanded.has(id) ? render(node.children, mobile, depth + 1) : [])]; }); return <> + {error &&

Could not save: {error}

}
{["Budget item", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => )} {render(tree.roots, false)} {tree.unfiled.length > 0 && }{render(tree.unfiled, false)} + + {render(tree.unassigned, false, 1)} + {tree.unassigned.length === 0 && }
{label}
Unfiled
Unassigned
No unassigned purchases this month.
-
    {render(tree.roots, true)}{tree.unfiled.length > 0 &&
  • Unfiled
  • }{render(tree.unfiled, true)}
+
    {render(tree.roots, true)}{tree.unfiled.length > 0 &&
  • Unfiled
  • }{render(tree.unfiled, true)}
  • Unassigned
  • {render(tree.unassigned, true, 1)}{tree.unassigned.length === 0 &&
  • No unassigned purchases this month.
  • }
; } diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index b3e31c5..dc040bf 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -98,7 +98,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { ...toPurchase(draft), ...(detachBill ? { source: undefined } : {}), }); - const destination = placement === "__ledger" ? undefined : placement === "__unfiled" ? null : placement; + const destination = draft.splits.length === 0 || 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)); @@ -315,7 +315,8 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { - {purchase &&
+ {purchase && draft.splits.length === 0 &&

This purchase appears under Unassigned in Budget.

} + {purchase && draft.splits.length > 0 &&
setPlacement(event.target.value)}>