feat: support unassigned purchases and allocation status
Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
This commit is contained in:
+14
-2
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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}"`);
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PostYearRoute() {
|
||||
{post.name} · {year}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allocated {fmt.money(row.totalAllocation, base)} · spent{" "}
|
||||
Allocated {row.hasAllocationRuleByMonth.some(Boolean) ? fmt.money(row.totalAllocation, base) : "—"} · spent{" "}
|
||||
{fmt.money(row.totalCharges, base)} · 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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user