diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fb3ee41..bb792e3 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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: diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index d959926..57b9355 100755 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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. diff --git a/src/domain/charges.ts b/src/domain/charges.ts index 25406b3..e230ba7 100644 --- a/src/domain/charges.ts +++ b/src/domain/charges.ts @@ -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, diff --git a/src/domain/types.ts b/src/domain/types.ts index d52171e..2eee7d1 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -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; diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index 243c1b8..a217640 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -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, diff --git a/src/domain/views.ts b/src/domain/views.ts index 01145d8..61e01fe 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -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), }; diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 5bc497b..38b9097 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -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. diff --git a/src/export/json.ts b/src/export/json.ts index 6d92bc1..586c788 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -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, + 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>).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}"`); diff --git a/src/export/ods.test.ts b/src/export/ods.test.ts index d9af74c..5404ce7 100644 --- a/src/export/ods.test.ts +++ b/src/export/ods.test.ts @@ -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"]); diff --git a/src/export/ods.ts b/src/export/ods.ts index da67ff7..a97eacb 100644 --- a/src/export/ods.ts +++ b/src/export/ods.ts @@ -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), diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 36e185f..7838ab3 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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; diff --git a/src/ui/bulkEntry.test.ts b/src/ui/bulkEntry.test.ts index d3dd6b4..a5363b8 100644 --- a/src/ui/bulkEntry.test.ts +++ b/src/ui/bulkEntry.test.ts @@ -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([]); }); diff --git a/src/ui/bulkEntry.ts b/src/ui/bulkEntry.ts index 690c8fc..923800f 100644 --- a/src/ui/bulkEntry.ts +++ b/src/ui/bulkEntry.ts @@ -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."); } diff --git a/src/ui/components/PlanEditor.test.tsx b/src/ui/components/PlanEditor.test.tsx new file mode 100644 index 0000000..859b5d4 --- /dev/null +++ b/src/ui/components/PlanEditor.test.tsx @@ -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( { 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( {}} />)); + expect(container.querySelector("button")!.disabled).toBe(true); + } finally { + act(() => root.unmount()); + Object.assign(store, original); + } +}); diff --git a/src/ui/components/PlanEditor.tsx b/src/ui/components/PlanEditor.tsx index 92201c6..2eb18ee 100644 --- a/src/ui/components/PlanEditor.tsx +++ b/src/ui/components/PlanEditor.tsx @@ -17,15 +17,13 @@ interface Props { onChange: (next: PurchaseDraft) => void; } -// A native 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) { {!dateReady && (

- Pick a date for this purchase first. + Pick a month or date for this purchase first.

)} diff --git a/src/ui/components/PostTable.test.tsx b/src/ui/components/PostTable.test.tsx new file mode 100644 index 0000000..db82986 --- /dev/null +++ b/src/ui/components/PostTable.test.tsx @@ -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()); + 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); + } +}); diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index 34d5477..1b6114c 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -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), }; } diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index ec4a814..884e436 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -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(() => 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 })); }} > - + {activePosts.map((post) => (