fix: keep unassigned purchases visible in budget
Continuous Integration / lint-and-test (push) Successful in 12s
Continuous Integration / docker-build-and-push (push) Successful in 32s

This commit is contained in:
Lucas Winther committed 2026-09-30 16:35:44 +02:00
1 parent f02c452d6c
commit dfbe584874
8 files changed
+80 -8

No files matched your search

+4 -1
View File
@@ -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
+13
View File
@@ -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)];
+7 -2
View File
@@ -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,
};
}
+12
View File
@@ -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);
+10
View File
@@ -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,
+22 -1
View File
@@ -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(<MemoryRouter><BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
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);
}
});
+9 -2
View File
@@ -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<Set<string>>(() => 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;
<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 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><span className="flex items-center gap-3">{node.purchase.splits.length === 0 && <button type="button" aria-label={`Create post for ${node.purchase.description}`} className="text-xs underline underline-offset-2 hover:text-budget-accent" onClick={() => void mutate(draft => createPostForPurchase(draft, node.purchase.id))}>Create post</button>}<PurchaseDialog monthId={monthId} purchase={node.purchase} trigger={<button type="button" className="text-xs underline underline-offset-2 hover:text-budget-accent">Edit</button>} /></span></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.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 && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
<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)}
<tbody><tr><th colSpan={6} className="border-b border-budget-rule pb-2 pt-5 text-left font-medium">Unassigned</th></tr></tbody>
{render(tree.unassigned, false, 1)}
{tree.unassigned.length === 0 && <tbody><tr><td colSpan={6} className="py-3 text-sm text-budget-ink-muted">No unassigned purchases this month.</td></tr></tbody>}
</table></div>
<ul className="space-y-3 sm:hidden">{render(tree.roots, true)}{tree.unfiled.length > 0 && <li className="border-b border-budget-rule pb-2 pt-4 font-medium">Unfiled</li>}{render(tree.unfiled, true)}</ul>
<ul className="space-y-3 sm:hidden">{render(tree.roots, true)}{tree.unfiled.length > 0 && <li className="border-b border-budget-rule pb-2 pt-4 font-medium">Unfiled</li>}{render(tree.unfiled, true)}<li className="border-b border-budget-rule pb-2 pt-4 font-medium">Unassigned</li>{render(tree.unassigned, true, 1)}{tree.unassigned.length === 0 && <li className="py-2 text-sm text-budget-ink-muted">No unassigned purchases this month.</li>}</ul>
</>;
}
+3 -2
View File
@@ -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) {
<SplitEditor draft={draft} posts={purchase ? editablePosts : activePosts} onChange={setDraft} />
{purchase && <div className="space-y-1">
{purchase && draft.splits.length === 0 && <p className="text-xs text-budget-ink-muted">This purchase appears under Unassigned in Budget.</p>}
{purchase && draft.splits.length > 0 && <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>