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 }; }