fix: keep unassigned purchases visible in budget
This commit is contained in:
1 parent
f02c452d6c
commit
dfbe584874
8 files changed
+80
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user