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:
1 parent
c1446db39f
commit
b0e8145d2e
31 files changed
+469
-80
No files matched your search
+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),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user