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