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 committed 2026-09-30 13:10:10 +02:00
1 parent c1446db39f
commit b0e8145d2e
31 files changed
+469 -80

No files matched your search

+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),
};