From 879fac999933b5a6bc49ca5641d291259d7da27e Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Fri, 22 Nov 2024 21:30:16 +1100 Subject: [PATCH] Implement income statement report --- src/components/DynamicReportComponent.vue | 47 ++++++ src/components/DynamicReportEntry.vue | 49 ++++++ src/db.ts | 12 ++ src/display.ts | 19 +++ src/main.ts | 1 + src/pages/GeneralLedgerView.vue | 2 +- src/pages/HomeView.vue | 2 +- src/pages/TransactionsView.vue | 14 +- src/pages/TrialBalanceView.vue | 5 +- src/reporting.ts | 100 ++++++++++-- src/reports/IncomeStatementReport.vue | 81 ++++++++++ src/reports/TrialBalanceReport.ts | 25 +++ src/reports/base.ts | 179 ++++++++++++++++++++++ 13 files changed, 516 insertions(+), 20 deletions(-) create mode 100644 src/components/DynamicReportComponent.vue create mode 100644 src/components/DynamicReportEntry.vue create mode 100644 src/reports/IncomeStatementReport.vue create mode 100644 src/reports/TrialBalanceReport.ts create mode 100644 src/reports/base.ts diff --git a/src/components/DynamicReportComponent.vue b/src/components/DynamicReportComponent.vue new file mode 100644 index 0000000..ffe025a --- /dev/null +++ b/src/components/DynamicReportComponent.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/components/DynamicReportEntry.vue b/src/components/DynamicReportEntry.vue new file mode 100644 index 0000000..49e945d --- /dev/null +++ b/src/components/DynamicReportEntry.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/src/db.ts b/src/db.ts index f4688e4..e1dcc03 100644 --- a/src/db.ts +++ b/src/db.ts @@ -180,6 +180,18 @@ export function joinedToTransactions(joinedTransactionPostings: JoinedTransactio return transactions; } +export async function getAccountsForKind(session: ExtendedDatabase, kind: string): Promise { + const rawAccountsForKind: {account: string}[] = await session.select( + `SELECT account + FROM account_configurations + WHERE kind = $1 + ORDER BY account`, + [kind] + ); + const accountsForKind = rawAccountsForKind.map((a) => a.account); + return accountsForKind; +} + export function serialiseAmount(quantity: number, commodity: string): string { // Pretty print the amount for an editable input if (quantity < 0) { diff --git a/src/display.ts b/src/display.ts index 639d7d5..f23ee36 100644 --- a/src/display.ts +++ b/src/display.ts @@ -39,3 +39,22 @@ export function ppWithCommodity(quantity: number, commodity: string): string { return pp(quantity) + ' ' + commodity; } } + +export function ppBracketed(quantity: number, link?: string): string { + // Pretty print the quantity with brackets for negative numbers + let text, space; + if (quantity >= 0) { + text = pp(quantity); + space = ' '; + } else { + text = '(' + pp(-quantity) + ')'; + space = ''; + } + + if (link) { + // Put the space outside of the hyperlink so it is not underlined + return '' + text + '' + space; + } else { + return text + space; + } +} diff --git a/src/main.ts b/src/main.ts index cc8fd08..8a64a16 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,6 +35,7 @@ async function initApp() { { path: '/balance-assertions/new', name: 'balance-assertions-new', component: () => import('./pages/NewBalanceAssertionView.vue') }, { path: '/chart-of-accounts', name: 'chart-of-accounts', component: () => import('./pages/ChartOfAccountsView.vue') }, { path: '/general-ledger', name: 'general-ledger', component: () => import('./pages/GeneralLedgerView.vue') }, + { path: '/income-statement', name: 'income-statement', component: () => import('./reports/IncomeStatementReport.vue') }, { path: '/journal', name: 'journal', component: () => import('./pages/JournalView.vue') }, { path: '/journal/edit/:id', name: 'journal-edit-transaction', component: () => import('./pages/EditTransactionView.vue') }, { path: '/journal/new', name: 'journal-new-transaction', component: () => import('./pages/NewTransactionView.vue') }, diff --git a/src/pages/GeneralLedgerView.vue b/src/pages/GeneralLedgerView.vue index 3ce3e7b..5eda877 100644 --- a/src/pages/GeneralLedgerView.vue +++ b/src/pages/GeneralLedgerView.vue @@ -83,7 +83,7 @@ const reportingWorkflow = new ReportingWorkflow(); await reportingWorkflow.generate(session); - transactions.value = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions); + transactions.value = reportingWorkflow.getTransactionsAtStage(ReportingStage.FINAL_STAGE); // Display transactions in reverse chronological order // We must sort here because they are returned by reportingWorkflow in order of ReportingStage diff --git a/src/pages/HomeView.vue b/src/pages/HomeView.vue index 7f07c06..e35ca6b 100644 --- a/src/pages/HomeView.vue +++ b/src/pages/HomeView.vue @@ -34,7 +34,7 @@
  • General ledger
  • Trial balance
  • - +
  • Income statement
  • diff --git a/src/pages/TransactionsView.vue b/src/pages/TransactionsView.vue index a4fe38d..891c963 100644 --- a/src/pages/TransactionsView.vue +++ b/src/pages/TransactionsView.vue @@ -58,14 +58,20 @@ const reportingWorkflow = new ReportingWorkflow(); await reportingWorkflow.generate(session); // This also ensures running balances are up to date - const transactionsRaw = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions); + const transactionsRaw = reportingWorkflow.getTransactionsAtStage(ReportingStage.FINAL_STAGE); // Filter only transactions affecting this account - transactions.value = transactionsRaw.filter((t) => t.postings.some((p) => p.account === route.params.account)); + const filteredTransactions = transactionsRaw.filter((t) => t.postings.some((p) => p.account === route.params.account)); - // Display transactions in reverse chronological order + // In order to correctly sort API transactions, we need to remember their indexes + const filteredTxnsWithIndexes = filteredTransactions.map((t, index) => [t, index] as [Transaction, number]); + + // Sort transactions in reverse chronological order // We must sort here because they are returned by reportingWorkflow in order of ReportingStage - transactions.value.sort((a, b) => (b.dt.localeCompare(a.dt)) || ((b.id ?? 0) - (a.id ?? 0))); + // Use Number.MAX_SAFE_INTEGER as ID for API transactions + filteredTxnsWithIndexes.sort(([t1, i1], [t2, i2]) => (t2.dt.localeCompare(t1.dt)) || ((t2.id ?? Number.MAX_SAFE_INTEGER) - (t1.id ?? Number.MAX_SAFE_INTEGER) || (i2 - i1))); + + transactions.value = filteredTxnsWithIndexes.map(([t, _idx]) => t); } load(); diff --git a/src/pages/TrialBalanceView.vue b/src/pages/TrialBalanceView.vue index 39f3c44..a29d1f5 100644 --- a/src/pages/TrialBalanceView.vue +++ b/src/pages/TrialBalanceView.vue @@ -53,7 +53,8 @@ import { db } from '../db.ts'; import { pp } from '../display.ts'; - import { ReportingStage, ReportingWorkflow, TrialBalanceReport } from '../reporting.ts'; + import { ReportingStage, ReportingWorkflow } from '../reporting.ts'; + import TrialBalanceReport from '../reports/TrialBalanceReport.ts'; const report = ref(null as TrialBalanceReport | null); @@ -72,7 +73,7 @@ const reportingWorkflow = new ReportingWorkflow(); await reportingWorkflow.generate(session); - report.value = reportingWorkflow.getReportAtStage(ReportingStage.OrdinaryAPITransactions, TrialBalanceReport) as TrialBalanceReport; + report.value = reportingWorkflow.getReportAtStage(ReportingStage.FINAL_STAGE, TrialBalanceReport) as TrialBalanceReport; } load(); diff --git a/src/reporting.ts b/src/reporting.ts index 65685fd..5feafcd 100644 --- a/src/reporting.ts +++ b/src/reporting.ts @@ -16,21 +16,44 @@ along with this program. If not, see . */ +import dayjs from 'dayjs'; + import { asCost } from './amounts.ts'; -import { JoinedTransactionPosting, StatementLine, Transaction, joinedToTransactions, totalBalances, totalBalancesAtDate } from './db.ts'; +import { DT_FORMAT, JoinedTransactionPosting, StatementLine, Transaction, db, getAccountsForKind, joinedToTransactions, totalBalances, totalBalancesAtDate } from './db.ts'; import { ExtendedDatabase } from './dbutil.ts'; +import { DrcrReport } from './reports/base.ts'; +import TrialBalanceReport from './reports/TrialBalanceReport.ts'; +import { IncomeStatementReport } from './reports/IncomeStatementReport.vue'; + export enum ReportingStage { // Load transactions from database TransactionsFromDatabase = 100, // Load unreconciled statement lines and other ordinary API transactions OrdinaryAPITransactions = 200, + + // Recognise accumulated surplus as equity + AccumulatedSurplusToEquity = 300, + + // Interim income statement considering only DB and ordinary API transactions + InterimIncomeStatement = 400, + + // Income tax estimation + //Tax = 500, + + // Final income statement + //IncomeStatement = 600, + + // Final balance sheet + //BalanceSheet = 700, + + FINAL_STAGE = InterimIncomeStatement } export class ReportingWorkflow { transactionsForStage: Map = new Map(); - reportsForStage: Map = new Map(); + reportsForStage: Map = new Map(); async generate(session: ExtendedDatabase, dt?: string) { // ------------------------ @@ -123,9 +146,71 @@ export class ReportingWorkflow { balances = applyTransactionsToBalances(balances, transactions); this.reportsForStage.set(ReportingStage.OrdinaryAPITransactions, [new TrialBalanceReport(balances)]); } + + // -------------------------- + // AccumulatedSurplusToEquity + + { + // Compute balances at end of last financial year + const last_eofy_date = dayjs(db.metadata.eofy_date).subtract(1, 'year'); + const balancesLastEofy = await totalBalancesAtDate(session, last_eofy_date.format('YYYY-MM-DD')); + + // Get income and expense accounts + const incomeAccounts = await getAccountsForKind(session, 'drcr.income'); + const expenseAccounts = await getAccountsForKind(session, 'drcr.expense'); + const pandlAccounts = [...incomeAccounts, ...expenseAccounts]; + pandlAccounts.sort(); + + // Prepare transactions + const transactions = []; + for (const account of pandlAccounts) { + if (balancesLastEofy.has(account)) { + const balanceLastEofy = balancesLastEofy.get(account)!; + if (balanceLastEofy === 0) { + continue; + } + + transactions.push(new Transaction( + null, + last_eofy_date.format(DT_FORMAT), + 'Accumulated surplus/deficit', + [ + { + id: null, + description: null, + account: account, + quantity: -balanceLastEofy, + commodity: db.metadata.reporting_commodity + }, + { + id: null, + description: null, + account: 'Accumulated surplus (deficit)', + quantity: balanceLastEofy, + commodity: db.metadata.reporting_commodity + }, + ] + )); + } + } + this.transactionsForStage.set(ReportingStage.AccumulatedSurplusToEquity, transactions); + + // Recompute balances + balances = applyTransactionsToBalances(balances, transactions); + this.reportsForStage.set(ReportingStage.AccumulatedSurplusToEquity, [new TrialBalanceReport(balances)]); + } + + // --------------- + // InterimIncomeStatement + + { + const incomeStatementReport = new IncomeStatementReport(); + await incomeStatementReport.generate(balances); + this.reportsForStage.set(ReportingStage.InterimIncomeStatement, [incomeStatementReport, new TrialBalanceReport(balances)]); + } } - getReportAtStage(stage: ReportingStage, reportType: any): Report { + getReportAtStage(stage: ReportingStage, reportType: any): DrcrReport { // TODO: This function needs generics const reportsForTheStage = this.reportsForStage.get(stage); if (!reportsForTheStage) { @@ -151,15 +236,6 @@ export class ReportingWorkflow { } } -interface Report { -} - -export class TrialBalanceReport implements Report { - constructor( - public balances: Map - ) {} -} - function applyTransactionsToBalances(balances: Map, transactions: Transaction[]): Map { // Initialise new balances const newBalances: Map = new Map([...balances.entries()]); diff --git a/src/reports/IncomeStatementReport.vue b/src/reports/IncomeStatementReport.vue new file mode 100644 index 0000000..0939429 --- /dev/null +++ b/src/reports/IncomeStatementReport.vue @@ -0,0 +1,81 @@ + + + + + + + + + diff --git a/src/reports/TrialBalanceReport.ts b/src/reports/TrialBalanceReport.ts new file mode 100644 index 0000000..56196c1 --- /dev/null +++ b/src/reports/TrialBalanceReport.ts @@ -0,0 +1,25 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { DrcrReport } from './base.ts'; + +export default class TrialBalanceReport implements DrcrReport { + constructor( + public balances: Map + ) {} +} diff --git a/src/reports/base.ts b/src/reports/base.ts new file mode 100644 index 0000000..fde0487 --- /dev/null +++ b/src/reports/base.ts @@ -0,0 +1,179 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { db, getAccountsForKind } from '../db.ts'; + +export interface DrcrReport { +} + +export interface DynamicReportNode { + id: string | null; + calculate(parent: DynamicReport | DynamicReportNode): void; +} + +export class DynamicReport implements DrcrReport { + constructor( + public title: string, + public entries: DynamicReportNode[] = [], + ) {} + + byId(id: string): DynamicReportNode | null { + // Get the DynamicReportNode with the given ID + for (const entry of this.entries) { + if (entry.id === id) { + return entry; + } + if (entry instanceof Section) { + const result = entry.byId(id); + if (result) { + return result; + } + } + } + return null; + } + + calculate() { + // Compute all subtotals + for (const entry of this.entries) { + entry.calculate(this); + } + } + + static async entriesForKind(balances: Map, kind: string, negate = false) { + // Get accounts associated with this kind + const accountsForKind = await getAccountsForKind(await db.load(), kind); + + // Return one entry for each such account + const entries = []; + for (const account of accountsForKind) { + if (balances.has(account)) { + const quantity = balances.get(account)!; + if (quantity === 0) { + continue; + } + + entries.push(new Entry( + account, + negate ? -quantity : quantity, + )); + } + } + + return entries; + } +} + +export class Entry implements DynamicReportNode { + constructor( + public text: string, + public quantity: number, + public id: string | null = null, + public visible = true, + public autoHide = false, + public link: string | null = null, + public heading = false, + public bordered = false, + ) {} + + calculate(_parent: DynamicReport | DynamicReportNode) {} +} + +export class Computed extends Entry { + constructor( + public text: string, + public calc: Function, + public id: string | null = null, + public visible = true, + public autoHide = false, + public link: string | null = null, + public heading = false, + public bordered = false, + ) { + super(text, null!, id, visible, autoHide, link, heading, bordered); + } + + calculate(_parent: DynamicReport | DynamicReportNode) { + // Calculate the value of this entry + this.quantity = this.calc(); + } +} + +export class Section implements DynamicReportNode { + constructor( + public title: string | null, + public entries: DynamicReportNode[] = [], + public id: string | null = null, + public visible = true, + public autoHide = false, + ) {} + + calculate(_parent: DynamicReport | DynamicReportNode) { + for (const entry of this.entries) { + entry.calculate(this); + } + } + + byId(id: string): DynamicReportNode | null { + // Get the DynamicReportNode with the given ID + for (const entry of this.entries) { + if (entry.id === id) { + return entry; + } + if (entry instanceof Section) { + const result = entry.byId(id); + if (result) { + return result; + } + } + } + return null; + } +} + +export class Spacer implements DynamicReportNode { + id = null; + + calculate(_parent: DynamicReport | DynamicReportNode) {} +} + +export class Subtotal extends Entry { + constructor( + public text: string, + public id: string | null = null, + public visible = true, + public bordered = false, + public floor = 0, + ) { + super(text, null!, id, visible, false /* autoHide */, null /* link */, true /* heading */, bordered); + } + + calculate(parent: DynamicReport | DynamicReportNode) { + // Calculate total amount + if (!(parent instanceof Section)) { + throw new Error('Attempt to calculate Subtotal not in Section'); + } + + this.quantity = 0; + for (const entry of parent.entries) { + if (entry instanceof Entry) { + this.quantity += entry.quantity; + } + } + } +}