feat: support unassigned purchases and allocation status
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 35s

Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
This commit is contained in:
Lucas Winther
2026-09-30 13:10:10 +02:00
parent c1446db39f
commit b0e8145d2e
31 changed files with 469 additions and 80 deletions
+14 -2
View File
@@ -47,8 +47,9 @@ remaining = carriedIn + allocation − charges (carried forward, monthly)
Unspent carries; overspend carries as debt. **Nothing else may recompute this.**
The month view, year matrix, summary, per-post views and the ODS export all
aggregate over `foldBalances` / `figuresFor`. A closing balance is read from
`byMonth[11].remaining`, never re-summed.
aggregate envelope figures over `foldBalances` / `figuresFor`. Overall Spent
also includes unassigned purchase slices, which have no envelope to fold. A
closing balance is read from `byMonth[11].remaining`, never re-summed.
A recurring cost adds a SECOND balance beside it, on its own carry:
@@ -156,6 +157,17 @@ those parts*. Both split modes collapse to one code path per slice.
Applying a split's raw value per-slice looks simpler and passes every percentage
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.
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
explicit zero rule, including a zero monthly override, does have an overspend
status when its balance is negative. The views use the presence of a rule rather
than infer it from the numeric allocation, since both cases fold to zero.
## Things the app deliberately does NOT validate
Adding a "missing" guard here is a regression, not a fix:
+5
View File
@@ -59,10 +59,15 @@ Confirmed and in place:
posts alone hold envelope balances.
- 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
balance, but is not labelled overspent. An explicit zero allocation is a
budget rule and can be overspent.
- Allocation is either a fixed amount or a percentage of that month's income.
- A purchase can be split across several posts, by percentage or by fixed
amounts, and can also be spread across future months as a payment plan. The
two compose.
- A purchase may have no post. It remains visible in its month and counts in
Spent, while leaving envelope balances unchanged.
- Per-month income, entered directly, varying month to month.
- Multi-currency (DKK/USD/EUR) against a user-maintained exchange-rate table,
with an optional on-demand fetch that fails soft and is never required.
+14
View File
@@ -125,6 +125,7 @@ export function chargesForPurchaseInMonth(
): Charge[] {
const slice = sliceAmountForMonth(purchase, monthId);
if (!slice) return [];
if (purchase.splits.length === 0) return [];
// Split proportions come from the WHOLE purchase, so a financed purchase
// divides each slice in the same ratio as the purchase as a whole — which is
@@ -140,6 +141,19 @@ export function chargesForPurchaseInMonth(
);
}
/** Spending with no envelope destination in one month, in base currency. */
export function looseChargesForMonth(dataset: Dataset, monthId: MonthId): number {
return dataset.purchases.reduce((sum, purchase) => {
if (purchase.splits.length !== 0) return sum;
const slice = sliceAmountForMonth(purchase, monthId);
if (!slice) return sum;
return roundMoney(
sum + toBase(slice, dataset.settings.baseCurrency, dataset.fxRates, dataset.settings.digits),
dataset.settings.digits,
);
}, 0);
}
/** Total charged per post in `monthId`, in base currency. */
export function chargesForMonth(
dataset: Dataset,
+2 -1
View File
@@ -169,7 +169,7 @@ export interface RecurringCost {
endedFrom?: IsoDate;
recurrence: Recurrence;
anchoring: Anchoring;
/** Same shape and same invariant as Purchase: exactly one absorbsRemainder. */
/** Bills require exactly one remainder-absorbing destination. */
splitMode: "percent" | "fixed";
splits: Split[];
}
@@ -183,6 +183,7 @@ export interface Purchase {
/** In the purchase's own currency. */
total: Money;
splitMode: "percent" | "fixed";
/** Empty means spent in the month without charging any post. */
splits: Split[];
/** null means the whole total is charged in the month of `date`. */
schedule: Schedule | null;
+56
View File
@@ -55,6 +55,33 @@ const data: Dataset = {
};
describe("monthView", () => {
test("counts a loose purchase as spent without charging a post", () => {
const loose: Dataset = { ...data, purchases: [{ ...spend("loose", "food", 75, "2026-01"), splits: [] }] };
const view = monthView(loose, "2026-01");
expect(view.totalCharges).toBe(75);
expect(view.rows.every((row) => row.figures.charges === 0)).toBe(true);
expect(view.rows[0]!.figures.remaining).toBe(2000);
});
test("counts financed loose slices in their months and converts currency", () => {
const loose: Dataset = {
...data,
fxRates: [{ currency: "EUR", baseUnitsPerOne: 7.5, updatedAt: "2026-01-01", source: "manual" }],
purchases: [{
...spend("loose", "food", 10, "2026-01"),
total: { amount: 10, currency: "EUR" },
splits: [],
schedule: { slices: [
{ month: "2026-01", amount: { amount: 4, currency: "EUR" } },
{ month: "2026-02", amount: { amount: 6, currency: "EUR" } },
] },
}],
};
expect(monthView(loose, "2026-01").totalCharges).toBe(30);
expect(monthView(loose, "2026-02").totalCharges).toBe(45);
expect(yearView(loose, 2026).totalCharges).toBe(75);
expect(summaryView(loose, "2026-01", "2026-02").looseCharges).toBe(75);
});
test("reports income, allocations, and the unallocated remainder", () => {
const view = monthView(data, "2026-01");
expect(view.income).toBe(10000);
@@ -104,6 +131,35 @@ describe("monthView", () => {
expect(view.overspentCount).toBe(0);
});
test("no allocation rule stays distinct from an explicit zero allocation", () => {
const unbudgeted = { ...post("none", 0, 0), rules: [] };
const zero = post("zero", 1, 0);
const caseData: Dataset = {
...data,
posts: [unbudgeted, zero],
purchases: [spend("a", "none", 25, "2026-01"), spend("b", "zero", 25, "2026-01")],
};
const view = monthView(caseData, "2026-01");
expect(view.rows.map((row) => row.hasAllocationRule)).toEqual([false, true]);
expect(view.rows.map((row) => row.figures.remaining)).toEqual([-25, -25]);
expect(view.overspentCount).toBe(1);
expect(yearView(caseData, 2026).rows.map((row) => row.hasAllocationRuleByMonth[0])).toEqual([false, true]);
});
test("a monthly override counts as an allocation rule even before any standing rule", () => {
const caseData: Dataset = {
...data,
posts: [{ ...post("none", 0, 0), rules: [] }],
months: [{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {
none: { kind: "fixed", amount: { amount: 0, currency: "DKK" } },
} }],
purchases: [spend("a", "none", 25, "2026-01")],
};
const view = monthView(caseData, "2026-01");
expect(view.rows[0]!.hasAllocationRule).toBe(true);
expect(view.overspentCount).toBe(1);
});
test("archived posts appear only when they have activity", () => {
const withArchived: Dataset = {
...data,
+35 -10
View File
@@ -1,4 +1,5 @@
import { incomeFor, isOverridden } from "./allocation.ts";
import { incomeFor, isOverridden, ruleForPost } from "./allocation.ts";
import { looseChargesForMonth } from "./charges.ts";
import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts";
import { roundMoney } from "./money.ts";
import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts";
@@ -8,6 +9,8 @@ export interface MonthPostRow {
post: Post;
figures: PostMonthFigures;
overridden: boolean;
/** An applicable standing rule or monthly override, including an explicit zero. */
hasAllocationRule: boolean;
}
export interface MonthViewModel {
@@ -15,11 +18,13 @@ export interface MonthViewModel {
income: number;
totalAllocation: number;
totalCharges: number;
looseCharges: number;
/** income - totalAllocation. Negative when allocations exceed income. */
unallocated: number;
/**
* How many rows are overspent — `remaining < 0`, so a post at exactly zero
* does not count. Computed here rather than in the component because it is
* How many budgeted rows are overspent — `remaining < 0` with an applicable
* allocation rule. A post with no rule still shows its negative balance,
* but has no budget to overspend. Computed here because it is
* the month-level answer to "did I overspend", and it belongs where the
* rest of the month's arithmetic is tested.
*/
@@ -57,17 +62,22 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
const figures = figuresFor(fold, post.id, monthId);
// Archived posts stay visible only while they still have activity.
if (post.archived && !hasActivity(figures)) continue;
rows.push({ post, figures, overridden: isOverridden(month, post.id) });
rows.push({
post,
figures,
overridden: isOverridden(month, post.id),
hasAllocationRule: ruleForPost(month, post, monthId) !== null,
});
}
const base = dataset.settings.baseCurrency;
const baseDigits = dataset.settings.digits;
const looseCharges = looseChargesForMonth(dataset, monthId);
const totalAllocation = roundMoney(
rows.reduce((sum, r) => sum + r.figures.allocation, 0),
baseDigits,
);
const totalCharges = roundMoney(
rows.reduce((sum, r) => sum + r.figures.charges, 0),
rows.reduce((sum, r) => sum + r.figures.charges, looseCharges),
baseDigits,
);
@@ -76,8 +86,9 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
income,
totalAllocation,
totalCharges,
looseCharges,
unallocated: roundMoney(income - totalAllocation, baseDigits),
overspentCount: rows.filter((r) => r.figures.remaining < 0).length,
overspentCount: rows.filter((r) => r.hasAllocationRule && r.figures.remaining < 0).length,
rows,
};
}
@@ -86,6 +97,7 @@ export interface YearPostRow {
post: Post;
/** Twelve entries, January to December. */
byMonth: PostMonthFigures[];
hasAllocationRuleByMonth: boolean[];
totalAllocation: number;
totalCharges: number;
/** December's remaining balance. */
@@ -98,22 +110,27 @@ export interface YearViewModel {
incomeByMonth: number[];
totalIncome: number;
totalCharges: number;
looseByMonth: number[];
totalLooseCharges: number;
rows: YearPostRow[];
}
export function yearView(dataset: Dataset, year: number): YearViewModel {
const months = monthsOfYear(year);
const fold = foldBalances(dataset, months[11]!);
const base = dataset.settings.baseCurrency;
const baseDigits = dataset.settings.digits;
const incomeByMonth = months.map((m) => incomeFor(dataset, m));
const looseByMonth = months.map((m) => looseChargesForMonth(dataset, m));
const rows: YearPostRow[] = visiblePosts(dataset).map((post) => {
const byMonth = months.map((m) => figuresFor(fold, post.id, m));
return {
post,
byMonth,
hasAllocationRuleByMonth: months.map((monthId) =>
ruleForPost(dataset.months.find((m) => m.id === monthId), post, monthId) !== null,
),
totalAllocation: roundMoney(
byMonth.reduce((sum, f) => sum + f.allocation, 0),
baseDigits,
@@ -135,9 +152,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
baseDigits,
),
totalCharges: roundMoney(
rows.reduce((sum, r) => sum + r.totalCharges, 0),
rows.reduce((sum, r) => sum + r.totalCharges, looseByMonth.reduce((a, b) => a + b, 0)),
baseDigits,
),
looseByMonth,
totalLooseCharges: roundMoney(looseByMonth.reduce((a, b) => a + b, 0), baseDigits),
rows,
};
}
@@ -148,6 +167,7 @@ export interface SummaryViewModel {
byPost: { post: Post; charges: number }[];
byMonth: { monthId: MonthId; charges: number }[];
totalCharges: number;
looseCharges: number;
totalIncome: number;
difference: number;
}
@@ -176,7 +196,7 @@ export function summaryView(
charges: roundMoney(
dataset.posts.reduce(
(sum, p) => sum + figuresFor(fold, p.id, monthId).charges,
0,
looseChargesForMonth(dataset, monthId),
),
baseDigits,
),
@@ -186,6 +206,10 @@ export function summaryView(
byMonth.reduce((sum, m) => sum + m.charges, 0),
baseDigits,
);
const looseCharges = roundMoney(
months.reduce((sum, monthId) => sum + looseChargesForMonth(dataset, monthId), 0),
baseDigits,
);
const totalIncome = roundMoney(
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
baseDigits,
@@ -197,6 +221,7 @@ export function summaryView(
byPost,
byMonth,
totalCharges,
looseCharges,
totalIncome,
difference: roundMoney(totalIncome - totalCharges, baseDigits),
};
+5 -6
View File
@@ -53,6 +53,11 @@ describe("round trip", () => {
});
describe("validation", () => {
test("accepts a purchase with no post and preserves it through export", () => {
const data = populated();
data.purchases[0]!.splits = [];
expect(parseDatasetJson(exportDatasetJson(data)).purchases[0]!.splits).toEqual([]);
});
test("rejects malformed JSON", () => {
expect(() => parseDatasetJson("{not json")).toThrow(ImportValidationError);
});
@@ -89,12 +94,6 @@ describe("validation", () => {
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/exactly one/i);
});
test("rejects a purchase with no splits", () => {
const data = populated();
data.purchases[0]!.splits = [];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/at least one split/i);
});
test("rejects an unsupported currency", () => {
const data = populated() as any;
// CHF, not GBP: GBP is one of the currencies the app ships with now.
+7 -5
View File
@@ -242,8 +242,8 @@ function requireRecurrence(recurrence: any, label: string): void {
* `distributeByWeight`/`distributeByAmount` branch in `domain/charges.ts`.
* One helper means the two write paths cannot silently drift into checking
* to different standards; the two loops used to each carry their own copy of
* the "no splits" / "one absorber" / "unknown post" checks, so this replaces
* both rather than adding a third copy.
* the "one absorber" / "unknown post" checks, so this replaces both rather
* than adding a third copy. Bills require a split; purchases may be unassigned.
*
* `splitMode` must be one of the two known modes: `charges.ts` picks its
* distribution function with `splitMode === "percent" ? ... : ...`, so any
@@ -266,15 +266,17 @@ function requireSplits(
splitMode: unknown,
label: string,
postIds: ReadonlySet<string>,
allowEmpty = false,
): void {
if (!SPLIT_MODES.has(splitMode as string)) {
throw new ImportValidationError(
`${label} has an unknown splitMode "${String(splitMode)}"`,
);
}
if (!Array.isArray(splits) || splits.length === 0) {
if (!Array.isArray(splits) || (splits.length === 0 && !allowEmpty)) {
throw new ImportValidationError(`${label} has no splits; at least one split is required`);
}
if (splits.length === 0) return;
const absorbers = (splits as Array<Record<string, unknown>>).filter(
(s) => s?.absorbsRemainder,
).length;
@@ -297,7 +299,7 @@ function requireSplits(
/**
* Validates far enough that the fold cannot throw on the imported data: every
* split points at a real post, every purchase has exactly one remainder
* split points at a real post, every assigned purchase has exactly one remainder
* absorber, every MonthId parses, every rule version resolves unambiguously
* to a number rather than to NaN or a missing rate, and every recurring
* cost's startDate is both granular enough for its recurrence kind
@@ -484,7 +486,7 @@ export function parseDatasetJson(text: string): Dataset {
if (!PURCHASE_DATE.test(purchase.date)) {
throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`);
}
requireSplits(purchase.splits, purchase.splitMode, label, postIds);
requireSplits(purchase.splits, purchase.splitMode, label, postIds, true);
for (const slice of purchase.schedule?.slices ?? []) {
if (!MONTH_ID.test(slice.month)) {
throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`);
+18
View File
@@ -25,6 +25,24 @@ function populated(): Dataset {
}
describe("buildWorkbook", () => {
test("exports an unassigned purchase as a row", () => {
const data = populated();
data.purchases[0]!.splits = [];
const purchases = buildWorkbook(data).find((sheet) => sheet.name === "Purchases")!;
expect(purchases.rows).toHaveLength(2);
expect(purchases.rows[1]![3]).toEqual({ kind: "text", value: "Unassigned" });
});
test("exports each unassigned purchase amount separately", () => {
const data = populated();
data.purchases[0]!.splits = [];
data.purchases.push({ ...data.purchases[0]!, id: "p2", total: { amount: 50, currency: "DKK" } });
const purchases = buildWorkbook(data).find((sheet) => sheet.name === "Purchases")!;
expect(purchases.rows.slice(1).map((row) => row[4])).toEqual([
{ kind: "number", value: 400 },
{ kind: "number", value: 50 },
]);
});
test("includes Summary, Months, Purchases, and one sheet per post", () => {
const names = buildWorkbook(populated()).map((s) => s.name);
expect(names.slice(0, 3)).toEqual(["Summary", "Months", "Purchases"]);
+18 -1
View File
@@ -1,5 +1,6 @@
import { zipSync, strToU8 } from "fflate";
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts";
import { chargesForPurchaseInMonth, looseChargesForMonth, sliceAmountForMonth } from "../domain/charges.ts";
import { toBase } from "../domain/fx.ts";
import { monthRange } from "../domain/months.ts";
import { roundMoney } from "../domain/money.ts";
import { datasetMonthSpan, monthView, summaryView } from "../domain/views.ts";
@@ -35,6 +36,15 @@ function summarySheet(dataset: Dataset, months: MonthId[], byMonth: FiguresByMon
]);
}
const loose = months.map((m) => looseChargesForMonth(dataset, m));
if (loose.some((amount) => amount !== 0)) {
rows.push([
text("Unassigned"),
...loose.map(num),
num(roundMoney(loose.reduce((a, b) => a + b, 0), dataset.settings.digits)),
]);
}
const summary = summaryView(dataset, months[0]!, months[months.length - 1]!);
rows.push([
text("Total"),
@@ -89,6 +99,13 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet {
dataset.fxRates,
dataset.settings.digits,
);
if (purchase.splits.length === 0) {
const amount = toBase(slice, base, dataset.fxRates, dataset.settings.digits);
rows.push([
text(monthId), text(purchase.date), text(purchase.description), text("Unassigned"),
num(amount), num(slice.amount), text(slice.currency), text(purchase.schedule ? "yes" : "no"),
]);
}
for (const charge of charges) {
rows.push([
text(monthId),
+10
View File
@@ -312,6 +312,16 @@ describe("purchases", () => {
expect(data.purchases[0]!.description).toBe("Dinner");
});
test("an edited purchase can lose its post and bill association", () => {
const data = draft();
const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id));
const purchase = actions.confirmOccurrence(data, bill.id, "2026-09");
actions.updatePurchase(data, purchase.id, { splits: [], source: undefined });
expect(data.purchases[0]!.splits).toEqual([]);
expect(data.purchases[0]!.source).toBeUndefined();
expect(monthView(data, "2026-09").looseCharges).toBe(30);
});
test("updatePurchase rounds the total amount, split values, and schedule slice amounts", () => {
const data = draft();
const postId = data.posts[0]!.id;
+5 -6
View File
@@ -76,6 +76,11 @@ describe("lineToDraft", () => {
});
describe("validateBulk", () => {
test("allows lines with no post", () => {
const loose = draft({ postId: "", lines: [line("Milk", 25)] });
expect(validateBulk(loose)).toEqual([]);
expect(toPurchases(loose)[0]!.splits).toEqual([]);
});
test("a well-formed batch has no errors", () => {
expect(validateBulk(draft({ lines: [line("Milk", 25), line("Bread", 30)] }))).toEqual([]);
});
@@ -98,12 +103,6 @@ describe("validateBulk", () => {
expect(errors.some((e) => e.includes("1") && /description/i.test(e))).toBe(true);
});
test("requires a post", () => {
expect(validateBulk(draft({ postId: "", lines: [line("Milk", 25)] }))).toContain(
"Choose a post for these lines.",
);
});
test("does NOT reject a negative amount — over-budget and refunds are allowed", () => {
expect(validateBulk(draft({ lines: [line("Refund", -25)] }))).toEqual([]);
});
+4 -6
View File
@@ -8,8 +8,9 @@ import { toPurchase, type PurchaseDraft } from "./purchaseForm.ts";
*
* Date, post and currency are shared across the batch and set once; a line is
* just a description and an amount. Each line becomes its own Purchase landing
* wholly in the shared post, built through the SAME `toPurchase` the single
* form uses, so there is one definition of what a purchase is.
* wholly in the shared post, or unassigned when no post is selected. The
* SAME `toPurchase` the single form uses builds it, so there is one definition
* of what a purchase is.
*/
export interface BulkLine {
@@ -85,7 +86,7 @@ export function lineToDraft(draft: BulkDraft, line: BulkLine): PurchaseDraft {
amount: line.amount,
currency: draft.currency,
splitMode: "percent",
splits: [{ postId: draft.postId, value: 100, absorbsRemainder: true }],
splits: draft.postId ? [{ postId: draft.postId, value: 100, absorbsRemainder: true }] : [],
plan: null,
};
}
@@ -99,9 +100,6 @@ export function validateBulk(draft: BulkDraft): string[] {
const errors: string[] = [];
const filled = filledLines(draft);
if (draft.postId === "") {
errors.push("Choose a post for these lines.");
}
if (filled.length === 0) {
errors.push("Add at least one line.");
}
+48
View File
@@ -0,0 +1,48 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { store } from "../../store/index.ts";
import { emptyDraft, type PurchaseDraft } from "../purchaseForm.ts";
import { PlanEditor } from "./PlanEditor.tsx";
test("a purchase dated by month can start a finance plan in that month", () => {
const original = { get: store.get, subscribe: store.subscribe };
const dataset = createSeedDataset("2026-09");
store.get = () => dataset;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
let draft: PurchaseDraft = { ...emptyDraft("2026-09", "food"), amount: 90 };
const render = () => root.render(<PlanEditor draft={draft} onChange={(next) => { draft = next; render(); }} />);
try {
act(render);
const button = container.querySelector("button")!;
expect(button.disabled).toBe(false);
act(() => button.click());
expect(draft.plan?.slices.map((slice) => slice.month)).toEqual(["2026-09", "2026-10", "2026-11"]);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
test("an incomplete purchase date cannot start a finance plan", () => {
const original = { get: store.get, subscribe: store.subscribe };
const dataset = createSeedDataset("2026-09");
store.get = () => dataset;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<PlanEditor draft={{ ...emptyDraft("2026-09", "food"), date: "" }} onChange={() => {}} />));
expect(container.querySelector("button")!.disabled).toBe(true);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
+7 -9
View File
@@ -17,15 +17,13 @@ interface Props {
onChange: (next: PurchaseDraft) => void;
}
// A native <input type="date"> reports "" until the user has filled in every
// field, and monthOf() throws on anything that isn't a well-formed
// YYYY-MM-DD. This is UI-level input validation, not a domain concern — the
// domain is right to throw on malformed input; the caller just must not call
// it with malformed input.
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function hasValidDate(draft: PurchaseDraft): boolean {
return ISO_DATE_PATTERN.test(draft.date);
try {
monthOf(draft.date);
return true;
} catch {
return false;
}
}
export function PlanEditor({ draft, onChange }: Props) {
@@ -53,7 +51,7 @@ export function PlanEditor({ draft, onChange }: Props) {
</Button>
{!dateReady && (
<p className="text-xs text-muted-foreground">
Pick a date for this purchase first.
Pick a month or date for this purchase first.
</p>
)}
</div>
+43
View File
@@ -0,0 +1,43 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router";
import { createSeedDataset } from "../../domain/seed.ts";
import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { PostTable } from "./PostTable.tsx";
test("an unbudgeted post shows no allocation or overspend status, unlike an explicit zero rule", () => {
const data = createSeedDataset("2026-09");
const [unbudgeted, zero] = data.posts;
unbudgeted!.name = "Unbudgeted";
unbudgeted!.rules = [];
zero!.name = "Zero rule";
zero!.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } } }];
data.purchases = [unbudgeted!, zero!].map((post, i) => ({
id: `p${i}`, date: "2026-09", description: post.name,
total: { amount: 25, currency: "DKK" }, splitMode: "percent" as const,
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null,
}));
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter><PostTable monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
const noneRow = container.querySelector('tbody[data-budget-name="Unbudgeted"]')!;
const zeroRow = container.querySelector('tbody[data-budget-name="Zero rule"]')!;
expect(noneRow.querySelectorAll("td")[2]!.textContent).toBe("—");
expect(noneRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(false);
expect(zeroRow.querySelectorAll("td")[2]!.textContent).toContain("0");
expect(zeroRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(true);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
+5 -5
View File
@@ -50,21 +50,21 @@ interface RowCells {
* plain function, not a component, and both table markups call it from inside
* a map.
*/
function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells {
function postRowCells({ figures, hasAllocationRule }: MonthPostRow, fmt: MoneyFormat): RowCells {
return {
carriedIn: fmt.signedAmount(figures.carriedIn),
carriedInTone:
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
: hasAllocationRule && figures.carriedIn < 0
? "text-overspend"
: "",
allocation: fmt.amount(figures.allocation),
allocation: hasAllocationRule ? fmt.amount(figures.allocation) : "—",
charges: fmt.amount(figures.charges),
remaining: fmt.signedAmount(figures.remaining),
remainingTone: figures.remaining < 0 ? "text-overspend" : "",
remainingTone: hasAllocationRule && figures.remaining < 0 ? "text-overspend" : "",
projected: fmt.signedAmount(figures.projected),
segments: carryMeterSegments(figures),
segments: carryMeterSegments(figures, hasAllocationRule),
};
}
+18 -2
View File
@@ -49,6 +49,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
.sort((a, b) => a.order - b.order);
const [open, setOpen] = useState(false);
const [detachBill, setDetachBill] = useState(false);
const [draft, setDraft] = useState<PurchaseDraft>(() =>
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -68,6 +69,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft);
function reset() {
setDetachBill(false);
setDraft(
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -86,7 +88,10 @@ 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));
else if (purchase) updatePurchase(data, purchase.id, {
...toPurchase(draft),
...(detachBill ? { source: undefined } : {}),
});
else addPurchase(data, toPurchase(draft));
}).then((saved) => {
if (!saved) return;
@@ -166,7 +171,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
setBulk((b) => ({ ...b, postId }));
}}
>
<option value="">Choose a post…</option>
<option value="">No post (unassigned)</option>
{activePosts.map((post) => (
<option key={post.id} value={post.id}>
{post.name}
@@ -301,6 +306,17 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
{purchase?.source && (
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={detachBill}
onChange={(event) => setDetachBill(event.target.checked)}
/>
<span>Unlink from bill. The occurrence will show as expected again.</span>
</label>
)}
<PlanEditor draft={draft} onChange={setDraft} />
</>
)}
+2 -2
View File
@@ -53,8 +53,8 @@ export function RuleHistory({ post }: Props) {
<div className="space-y-3 rounded-md border border-budget-rule p-3">
{post.rules.length === 0 ? (
<p className="text-xs text-muted-foreground">
Not budgeted. Until a rule starts, this post is allocated nothing —
spending still records, and shows as overspend.
Not budgeted. Until a rule starts, this post is allocated nothing.
Spending still records without an overspend status.
</p>
) : (
<table className="w-full text-xs">
+22
View File
@@ -39,6 +39,26 @@ export function SplitEditor({ draft, posts, onChange }: Props) {
return (
<fieldset className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={draft.splits.length === 0}
onChange={(event) =>
onChange({
...draft,
splits: event.target.checked
? []
: posts[0]
? [{ postId: posts[0].id, value: 100, absorbsRemainder: true }]
: [],
})
}
disabled={posts.length === 0 && draft.splits.length === 0}
/>
No post — leave this purchase unassigned
</label>
{draft.splits.length > 0 && (
<>
<div className="flex items-center justify-between">
<Label>Split across posts</Label>
<Segmented
@@ -136,6 +156,8 @@ export function SplitEditor({ draft, posts, onChange }: Props) {
: `${fmt.amount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
</span>
</div>
</>
)}
</fieldset>
);
}
+11 -2
View File
@@ -59,7 +59,7 @@ export function YearMatrix({ view, mode }: Props) {
<td
key={i}
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
mode === "balance" && value < 0 ? "text-overspend" : ""
mode === "balance" && row.hasAllocationRuleByMonth[i] && value < 0 ? "text-overspend" : ""
}`}
>
{fmt.amount(value)}
@@ -68,13 +68,22 @@ export function YearMatrix({ view, mode }: Props) {
})}
<td
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
mode === "balance" && row.hasAllocationRuleByMonth[11] && row.closingBalance < 0 ? "text-overspend" : ""
}`}
>
{fmt.amount(mode === "spend" ? row.totalCharges : row.closingBalance)}
</td>
</tr>
))}
{mode === "spend" && view.totalLooseCharges !== 0 && (
<tr className="border-b border-budget-rule text-budget-ink-muted">
<td className="py-2">Unassigned</td>
{view.looseByMonth.map((amount, i) => (
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(amount)}</td>
))}
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(view.totalLooseCharges)}</td>
</tr>
)}
</tbody>
</table>
</div>
+5
View File
@@ -69,6 +69,11 @@ test("overspending paints past the end of the allocation and leaves no unspent t
]);
});
test("spending without an allocation rule uses the spending color, not overspend", () => {
const segments = carryMeterSegments(figures({ charges: 250, remaining: -250 }), false);
expect(widths(segments)).toEqual([["--budget-accent", 100]]);
});
test("a negative carried-in balance contributes no surplus segment", () => {
// Transport in the demo data: behind by 1600, allocated 1100, spent 2700.
// The debt is not on the meter — only the red Carried-in figure says why the
+13 -2
View File
@@ -43,15 +43,26 @@ function scaled(parts: [string, number][], scale: number): MeterSegment[] {
* --overspend spend beyond the allocation
*
* Negatives are clamped rather than inverted. A carried-in *debt* therefore
* does not appear here at all — only the red Carried-in figure beside the
* does not appear here at all — only the Carried-in figure beside the
* meter says why the post is behind — and a net refund reads as no spend.
* Both are deliberate: a segment of negative width would put the gradient's
* stops out of order, which paints something arbitrary instead of failing.
* Without an allocation rule, spending uses the ordinary spend color: there
* is no budget threshold to paint as overspent.
*/
export function carryMeterSegments(figures: PostMonthFigures): MeterSegment[] {
export function carryMeterSegments(
figures: PostMonthFigures,
hasAllocationRule = true,
): MeterSegment[] {
const surplus = Math.max(figures.carriedIn, 0);
const allocation = Math.max(figures.allocation, 0);
const charges = Math.max(figures.charges, 0);
if (!hasAllocationRule) {
return scaled(
[["--surplus", surplus], ["--budget-accent", charges]],
surplus + charges,
);
}
const scale = surplus + Math.max(allocation, charges);
if (scale <= 0) return EMPTY_TRACK;
+4 -6
View File
@@ -56,6 +56,10 @@ describe("splitBalance", () => {
});
describe("validatePurchase", () => {
test("allows a purchase with no post", () => {
expect(validatePurchase(draft({ splits: [] }))).toEqual([]);
expect(toPurchase(draft({ splits: [] })).splits).toEqual([]);
});
test("a well-formed draft has no errors", () => {
expect(validatePurchase(draft())).toEqual([]);
});
@@ -72,12 +76,6 @@ describe("validatePurchase", () => {
);
});
test("requires at least one split", () => {
expect(validatePurchase(draft({ splits: [] }))).toContain(
"Add at least one post to split across.",
);
});
test("requires exactly one remainder absorber", () => {
const none = draft({
splits: [{ postId: "food", value: 100, absorbsRemainder: false }],
+2 -5
View File
@@ -39,7 +39,7 @@ export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft {
amount: 0,
currency: "DKK",
splitMode: "percent",
splits: [{ postId, value: 100, absorbsRemainder: true }],
splits: postId ? [{ postId, value: 100, absorbsRemainder: true }] : [],
plan: null,
};
}
@@ -60,10 +60,7 @@ export function validatePurchase(draft: PurchaseDraft): string[] {
if (draft.amount === 0) {
errors.push("Enter an amount other than zero.");
}
if (draft.splits.length === 0) {
errors.push("Add at least one post to split across.");
}
if (draft.splits.filter((s) => s.absorbsRemainder).length !== 1) {
if (draft.splits.length > 0 && draft.splits.filter((s) => s.absorbsRemainder).length !== 1) {
errors.push("Choose exactly one post to absorb rounding.");
}
const postIds = draft.splits.map((s) => s.postId);
+6
View File
@@ -112,6 +112,9 @@ export function MonthRoute() {
<div className="mt-4 flex flex-wrap items-end justify-between gap-x-10 gap-y-2 border-t border-budget-rule pt-3">
<dl>
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
{view.looseCharges !== 0 && (
<Stat label="Unassigned">{fmt.money(view.looseCharges, base)}</Stat>
)}
</dl>
{/* Rendered in both directions. "No posts overspent" is a real answer
to the app's second question, and a line that only appears when
@@ -226,6 +229,9 @@ function PurchaseRow({
financed
</span>
)}
{purchase.splits.length === 0 && (
<span className="shrink-0 text-xs text-budget-ink-muted">unassigned</span>
)}
<span className="font-money shrink-0 tabular-nums">
{fmt.money(slice.amount, slice.currency)}
</span>
+68
View File
@@ -0,0 +1,68 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router";
import { createSeedDataset } from "../../domain/seed.ts";
import { yearView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { YearMatrix } from "../components/YearMatrix.tsx";
import { PostMonthRoute } from "./PostMonthRoute.tsx";
import { PostYearRoute } from "./PostYearRoute.tsx";
function withUnbudgetedPost(run: (container: HTMLDivElement, postId: string) => void) {
const data = createSeedDataset("2026-09");
const post = data.posts[0]!;
post.rules = [];
data.purchases.push({
id: "unbudgeted-purchase", date: "2026-09", description: "Unbudgeted spending",
total: { amount: 25, currency: "DKK" }, splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null,
});
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter key="month" initialEntries={[`/post/${post.id}/month/2026-09`]}>
<Routes>
<Route path="/post/:postId/month/:monthId" element={<PostMonthRoute />} />
</Routes>
</MemoryRouter>));
run(container, post.id);
act(() => root.render(<MemoryRouter key="year" initialEntries={[`/post/${post.id}/year/2026`]}>
<Routes><Route path="/post/:postId/year/:year" element={<PostYearRoute />} /></Routes>
</MemoryRouter>));
run(container, post.id);
act(() => root.render(<MemoryRouter key="matrix"><YearMatrix view={yearView(data, 2026)} mode="balance" /></MemoryRouter>));
run(container, post.id);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
}
test("post detail and year balances stay neutral when no allocation rule exists", () => {
let pass = 0;
withUnbudgetedPost((container) => {
pass++;
if (pass === 1) {
const details = container.querySelector("dl")!;
expect(details.querySelectorAll("dd")[1]!.textContent).toBe("—");
expect(details.querySelectorAll("dd")[3]!.classList.contains("text-overspend")).toBe(false);
} else if (pass === 2) {
expect(container.querySelector("p")!.textContent).toContain("Allocated —");
const september = [...container.querySelectorAll("tr")].find((row) => row.textContent?.includes("2026-09"))!;
expect(september.querySelectorAll("td")[2]!.textContent).toBe("—");
expect(september.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(false);
} else {
const row = container.querySelector("tbody tr:nth-child(2)")!;
expect(row.querySelectorAll("td")[9]!.classList.contains("text-overspend")).toBe(false);
expect(row.querySelectorAll("td")[13]!.classList.contains("text-overspend")).toBe(false);
}
});
});
+3 -3
View File
@@ -68,7 +68,7 @@ export function PostMonthRoute() {
className={`font-money ${
(row?.figures.carriedIn ?? 0) > 0
? "text-surplus"
: (row?.figures.carriedIn ?? 0) < 0
: row?.hasAllocationRule && (row?.figures.carriedIn ?? 0) < 0
? "text-overspend"
: ""
}`}
@@ -78,7 +78,7 @@ export function PostMonthRoute() {
</div>
<div>
<dt className="text-muted-foreground">Allocated</dt>
<dd className="font-money">{fmt.money(row?.figures.allocation ?? 0, base)}</dd>
<dd className="font-money">{row?.hasAllocationRule ? fmt.money(row.figures.allocation, base) : "—"}</dd>
</div>
<div>
<dt className="text-muted-foreground">Spent</dt>
@@ -87,7 +87,7 @@ export function PostMonthRoute() {
<div>
<dt className="text-muted-foreground">Remaining</dt>
<dd
className={`font-money ${(row?.figures.remaining ?? 0) < 0 ? "text-overspend" : ""}`}
className={`font-money ${row?.hasAllocationRule && (row.figures.remaining < 0) ? "text-overspend" : ""}`}
>
{fmt.signedMoney(row?.figures.remaining ?? 0, base)}
</dd>
+5 -4
View File
@@ -21,7 +21,7 @@ export function PostYearRoute() {
{post.name} &middot; {year}
</h1>
<p className="text-sm text-muted-foreground">
Allocated {fmt.money(row.totalAllocation, base)} &middot; spent{" "}
Allocated {row.hasAllocationRuleByMonth.some(Boolean) ? fmt.money(row.totalAllocation, base) : "—"} &middot; spent{" "}
{fmt.money(row.totalCharges, base)} &middot; closing balance{" "}
{fmt.signedMoney(row.closingBalance, base)}
</p>
@@ -40,6 +40,7 @@ export function PostYearRoute() {
<tbody>
{view.months.map((month, i) => {
const figures = row.byMonth[i]!;
const hasAllocationRule = row.hasAllocationRuleByMonth[i]!;
return (
<tr key={month} className="border-b last:border-0">
<td className="py-2">
@@ -51,18 +52,18 @@ export function PostYearRoute() {
className={`font-money py-2 text-right ${
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
: hasAllocationRule && figures.carriedIn < 0
? "text-overspend"
: ""
}`}
>
{fmt.signedMoney(figures.carriedIn, base)}
</td>
<td className="font-money py-2 text-right">{fmt.amount(figures.allocation)}</td>
<td className="font-money py-2 text-right">{hasAllocationRule ? fmt.amount(figures.allocation) : "—"}</td>
<td className="font-money py-2 text-right">{fmt.amount(figures.charges)}</td>
<td
className={`font-money py-2 text-right font-medium ${
figures.remaining < 0 ? "text-overspend" : ""
hasAllocationRule && figures.remaining < 0 ? "text-overspend" : ""
}`}
>
{fmt.signedMoney(figures.remaining, base)}
+13 -2
View File
@@ -93,7 +93,8 @@ export function SummaryRoute() {
</thead>
<tbody>
{groupBy === "post"
? view.byPost.map((entry) => (
? <>
{view.byPost.map((entry) => (
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
<td className="py-2.5">{entry.post.name}</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.money(entry.charges, base)}</td>
@@ -103,7 +104,17 @@ export function SummaryRoute() {
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
</td>
</tr>
))
))}
{view.looseCharges !== 0 && (
<tr className="border-b border-budget-rule text-budget-ink-muted">
<td className="py-2.5">Unassigned</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(view.looseCharges)}</td>
<td className="font-money py-2.5 pl-6 text-right">
{view.totalCharges === 0 ? "—" : `${((view.looseCharges / view.totalCharges) * 100).toFixed(1)}%`}
</td>
</tr>
)}
</>
: view.byMonth.map((entry) => (
<tr key={entry.monthId} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
<td className="py-2.5">
+1 -1
View File
@@ -42,7 +42,7 @@ export function YearRoute() {
</Section>
<Section
title="Posts by month"
title={mode === "spend" ? "Spending by month" : "Posts by month"}
action={
<Segmented
label="Figures to show"