Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
253 lines
7.5 KiB
TypeScript
253 lines
7.5 KiB
TypeScript
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";
|
|
import type { Dataset, MonthId, Post } from "./types.ts";
|
|
|
|
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 {
|
|
monthId: MonthId;
|
|
income: number;
|
|
totalAllocation: number;
|
|
totalCharges: number;
|
|
looseCharges: number;
|
|
/** income - totalAllocation. Negative when allocations exceed income. */
|
|
unallocated: number;
|
|
/**
|
|
* 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.
|
|
*/
|
|
overspentCount: number;
|
|
rows: MonthPostRow[];
|
|
}
|
|
|
|
function hasActivity(figures: PostMonthFigures): boolean {
|
|
// `remaining` and `projected` are both fully derived from these five raw
|
|
// fields, so checking all five is what keeps a post visible whenever either
|
|
// column would show a nonzero figure. `expected` and `projectedCarriedIn`
|
|
// are the two the recurring-costs feature added: an archived post whose
|
|
// only activity this month is an unconfirmed occurrence has a zero
|
|
// `remaining` but a nonzero `projected`, and must not be dropped.
|
|
return (
|
|
figures.carriedIn !== 0 ||
|
|
figures.allocation !== 0 ||
|
|
figures.charges !== 0 ||
|
|
figures.expected !== 0 ||
|
|
figures.projectedCarriedIn !== 0
|
|
);
|
|
}
|
|
|
|
function visiblePosts(dataset: Dataset): Post[] {
|
|
return [...dataset.posts].sort((a, b) => a.order - b.order);
|
|
}
|
|
|
|
export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
|
|
const fold = foldBalances(dataset, monthId);
|
|
const month = dataset.months.find((m) => m.id === monthId);
|
|
const income = incomeFor(dataset, monthId);
|
|
|
|
const rows: MonthPostRow[] = [];
|
|
for (const post of visiblePosts(dataset)) {
|
|
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),
|
|
hasAllocationRule: ruleForPost(month, post, monthId) !== null,
|
|
});
|
|
}
|
|
|
|
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, looseCharges),
|
|
baseDigits,
|
|
);
|
|
|
|
return {
|
|
monthId,
|
|
income,
|
|
totalAllocation,
|
|
totalCharges,
|
|
looseCharges,
|
|
unallocated: roundMoney(income - totalAllocation, baseDigits),
|
|
overspentCount: rows.filter((r) => r.hasAllocationRule && r.figures.remaining < 0).length,
|
|
rows,
|
|
};
|
|
}
|
|
|
|
export interface YearPostRow {
|
|
post: Post;
|
|
/** Twelve entries, January to December. */
|
|
byMonth: PostMonthFigures[];
|
|
hasAllocationRuleByMonth: boolean[];
|
|
totalAllocation: number;
|
|
totalCharges: number;
|
|
/** December's remaining balance. */
|
|
closingBalance: number;
|
|
}
|
|
|
|
export interface YearViewModel {
|
|
year: number;
|
|
months: MonthId[];
|
|
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 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,
|
|
),
|
|
totalCharges: roundMoney(
|
|
byMonth.reduce((sum, f) => sum + f.charges, 0),
|
|
baseDigits,
|
|
),
|
|
closingBalance: byMonth[11]!.remaining,
|
|
};
|
|
});
|
|
|
|
return {
|
|
year,
|
|
months,
|
|
incomeByMonth,
|
|
totalIncome: roundMoney(
|
|
incomeByMonth.reduce((a, b) => a + b, 0),
|
|
baseDigits,
|
|
),
|
|
totalCharges: roundMoney(
|
|
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,
|
|
};
|
|
}
|
|
|
|
export interface SummaryViewModel {
|
|
from: MonthId;
|
|
to: MonthId;
|
|
byPost: { post: Post; charges: number }[];
|
|
byMonth: { monthId: MonthId; charges: number }[];
|
|
totalCharges: number;
|
|
looseCharges: number;
|
|
totalIncome: number;
|
|
difference: number;
|
|
}
|
|
|
|
export function summaryView(
|
|
dataset: Dataset,
|
|
from: MonthId,
|
|
to: MonthId,
|
|
): SummaryViewModel {
|
|
const months = monthRange(from, to);
|
|
const base = dataset.settings.baseCurrency;
|
|
const baseDigits = dataset.settings.digits;
|
|
const fold: Fold =
|
|
months.length > 0 ? foldBalances(dataset, months[months.length - 1]!) : new Map();
|
|
|
|
const byPost = visiblePosts(dataset).map((post) => ({
|
|
post,
|
|
charges: roundMoney(
|
|
months.reduce((sum, m) => sum + figuresFor(fold, post.id, m).charges, 0),
|
|
baseDigits,
|
|
),
|
|
}));
|
|
|
|
const byMonth = months.map((monthId) => ({
|
|
monthId,
|
|
charges: roundMoney(
|
|
dataset.posts.reduce(
|
|
(sum, p) => sum + figuresFor(fold, p.id, monthId).charges,
|
|
looseChargesForMonth(dataset, monthId),
|
|
),
|
|
baseDigits,
|
|
),
|
|
}));
|
|
|
|
const totalCharges = roundMoney(
|
|
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,
|
|
);
|
|
|
|
return {
|
|
from,
|
|
to,
|
|
byPost,
|
|
byMonth,
|
|
totalCharges,
|
|
looseCharges,
|
|
totalIncome,
|
|
difference: roundMoney(totalIncome - totalCharges, baseDigits),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The months worth rendering: from the fold start to the latest month touched
|
|
* by any month record or purchase slice, including future finance-plan slices.
|
|
*/
|
|
export function datasetMonthSpan(dataset: Dataset): { from: MonthId; to: MonthId } {
|
|
const from = dataset.settings.foldStartMonth;
|
|
let to = from;
|
|
|
|
const consider = (candidate: MonthId) => {
|
|
if (compareMonths(candidate, to) > 0) to = candidate;
|
|
};
|
|
|
|
for (const month of dataset.months) consider(month.id);
|
|
for (const purchase of dataset.purchases) {
|
|
if (purchase.schedule) {
|
|
for (const slice of purchase.schedule.slices) consider(slice.month);
|
|
} else {
|
|
consider(monthOf(purchase.date));
|
|
}
|
|
}
|
|
|
|
return { from, to };
|
|
}
|