diff --git a/src/db.ts b/src/db.ts index a426f0e..84ddd34 100644 --- a/src/db.ts +++ b/src/db.ts @@ -60,10 +60,10 @@ export const db = reactive({ }, }); -export async function totalBalances(session: ExtendedDatabase): Promise<{account: string, quantity: number}[]> { +export async function totalBalances(session: ExtendedDatabase): Promise> { await updateRunningBalances(session); - return await session.select(` + const resultsRaw: {account: string, quantity: number}[] = await session.select(` SELECT p3.account AS account, running_balance AS quantity FROM ( SELECT p1.account, max(p2.transaction_id) AS max_tid FROM @@ -74,6 +74,8 @@ export async function totalBalances(session: ExtendedDatabase): Promise<{account ) p3 JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id ORDER BY account `); + + return new Map(resultsRaw.map((x) => [x.account, x.quantity])); } export async function updateRunningBalances(session: ExtendedDatabase) { diff --git a/src/pages/GeneralLedgerView.vue b/src/pages/GeneralLedgerView.vue index 71b0d16..276dfa9 100644 --- a/src/pages/GeneralLedgerView.vue +++ b/src/pages/GeneralLedgerView.vue @@ -66,8 +66,9 @@ import { onUnmounted, ref, watch } from 'vue'; import { asCost } from '../amounts.ts'; - import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts'; + import { Transaction, db } from '../db.ts'; import { pp, ppWithCommodity } from '../display.ts'; + import { ReportingStage, ReportingWorkflow } from '../reporting.ts'; import { renderComponent } from '../webutil.ts'; const commodityDetail = ref(false); @@ -77,15 +78,14 @@ async function load() { const session = await db.load(); + const reportingWorkflow = new ReportingWorkflow(); + await reportingWorkflow.generate(session); - const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select( - `SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity - FROM transactions - JOIN postings ON transactions.id = postings.transaction_id - ORDER BY dt DESC, transaction_id DESC, postings.id` - ); + transactions.value = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions); - transactions.value = joinedToTransactions(joinedTransactionPostings); + // Display 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))); } function renderTable() { diff --git a/src/pages/StatementLinesView.vue b/src/pages/StatementLinesView.vue index e36b1af..b672521 100644 --- a/src/pages/StatementLinesView.vue +++ b/src/pages/StatementLinesView.vue @@ -91,7 +91,7 @@ import { ppWithCommodity } from '../display.ts'; const session = await db.load(); const joinedStatementLines: any[] = await session.select( - `SELECT statement_lines.id, source_account, statement_lines.dt, statement_lines.description, statement_lines.quantity, statement_lines.balance, statement_lines.commodity, p2.transaction_id, p2.account AS posting_account + `SELECT statement_lines.*, p2.transaction_id, p2.account AS posting_account FROM statement_lines LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id LEFT JOIN postings ON statement_line_reconciliations.posting_id = postings.id diff --git a/src/pages/TransactionsView.vue b/src/pages/TransactionsView.vue index 9d809d7..9fc8526 100644 --- a/src/pages/TransactionsView.vue +++ b/src/pages/TransactionsView.vue @@ -41,7 +41,8 @@ import { ref } from 'vue'; import { useRoute } from 'vue-router'; - import { JoinedTransactionPosting, Transaction, db, joinedToTransactions, updateRunningBalances } from '../db.ts'; + import { Transaction, db } from '../db.ts'; + import { ReportingStage, ReportingWorkflow } from '../reporting.ts'; import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue'; import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue'; @@ -52,20 +53,17 @@ async function load() { const session = await db.load(); + const reportingWorkflow = new ReportingWorkflow(); + await reportingWorkflow.generate(session); // This also ensures running balances are up to date - // Ensure running balances are up to date because we use these - await updateRunningBalances(session); + const transactionsRaw = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions); - const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select( - `SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity, running_balance - FROM transactions - JOIN postings ON transactions.id = postings.transaction_id - WHERE transactions.id IN (SELECT transaction_id FROM postings WHERE postings.account = $1) - ORDER by dt DESC, transaction_id DESC, postings.id`, - [route.params.account] - ); + // Filter only transactions affecting this account + transactions.value = transactionsRaw.filter((t) => t.postings.some((p) => p.account === route.params.account)); - transactions.value = joinedToTransactions(joinedTransactionPostings); + // Display 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))); } load(); diff --git a/src/pages/TransactionsWithoutCommodityView.vue b/src/pages/TransactionsWithoutCommodityView.vue index 7f6e6dc..65d943c 100644 --- a/src/pages/TransactionsWithoutCommodityView.vue +++ b/src/pages/TransactionsWithoutCommodityView.vue @@ -60,6 +60,19 @@ let clusterize: Clusterize | null = null; function renderTable() { + // Recompute running balances + // This is necessary because running_balance is cached only considering database transactions + let balance = 0; + for (let i = transactions.length - 1; i >= 0; i--) { + const transaction = transactions[i]; + for (const posting of transaction.postings) { + if (posting.account === route.params.account) { + balance += asCost(posting.quantity, posting.commodity); + posting.running_balance = balance; // We should absolutely not commit this to the database! + } + } + } + // Render table const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon const rows = []; diff --git a/src/pages/TrialBalanceView.vue b/src/pages/TrialBalanceView.vue index f9e0e55..39f3c44 100644 --- a/src/pages/TrialBalanceView.vue +++ b/src/pages/TrialBalanceView.vue @@ -21,7 +21,7 @@ Trial balance - +
@@ -30,19 +30,19 @@ - - + + - - + +
Account
{{ account.account }}
{{ account }} - + - +
Total{{ pp(total_dr) }}{{ pp(-total_cr) }}{{ pp(total_dr!) }}{{ pp(-total_cr!) }}
@@ -51,17 +51,28 @@ diff --git a/src/reporting.ts b/src/reporting.ts new file mode 100644 index 0000000..b533bd9 --- /dev/null +++ b/src/reporting.ts @@ -0,0 +1,154 @@ +/* + 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 { asCost } from './amounts.ts'; +import { JoinedTransactionPosting, StatementLine, Transaction, joinedToTransactions, totalBalances } from './db.ts'; +import { ExtendedDatabase } from './dbutil.ts'; + +export enum ReportingStage { + // Load transactions from database + TransactionsFromDatabase = 100, + + // Load unreconciled statement lines and other ordinary API transactions + OrdinaryAPITransactions = 200, +} + +export class ReportingWorkflow { + transactionsForStage: Map = new Map(); + reportsForStage: Map = new Map(); + + async generate(session: ExtendedDatabase) { + // ------------------------ + // TransactionsFromDatabase + + let balances: Map; + + { + // Load balances from database + balances = await totalBalances(session); + this.reportsForStage.set(ReportingStage.TransactionsFromDatabase, [new TrialBalanceReport(balances)]); + + // Load transactions from database + const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select( + `SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity, running_balance + FROM transactions + JOIN postings ON transactions.id = postings.transaction_id + ORDER BY dt, transaction_id, postings.id` + ); + const transactions = joinedToTransactions(joinedTransactionPostings); + this.transactionsForStage.set(ReportingStage.TransactionsFromDatabase, transactions); + } + + // ----------------------- + // OrdinaryAPITransactions + + { + // Get unreconciled statement lines + const unreconciledStatementLines: StatementLine[] = await session.select( + // On testing, JOIN is much faster than WHERE NOT EXISTS + `SELECT statement_lines.* FROM statement_lines + LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id + WHERE statement_line_reconciliations.id IS NULL` + ); + + const transactions = []; + for (const line of unreconciledStatementLines) { + const unclassifiedAccount = line.quantity >= 0 ? 'Unclassified Statement Line Debits' : 'Unclassified Statement Line Credits'; + transactions.push(new Transaction( + null, + line.dt, + line.description, + [ + { + id: null, + description: null, + account: line.source_account, + quantity: line.quantity, + commodity: line.commodity + }, + { + id: null, + description: null, + account: unclassifiedAccount, + quantity: -line.quantity, + commodity: line.commodity + } + ] + )); + } + this.transactionsForStage.set(ReportingStage.OrdinaryAPITransactions, transactions); + + // Recompute balances + balances = applyTransactionsToBalances(balances, transactions); + this.reportsForStage.set(ReportingStage.OrdinaryAPITransactions, [new TrialBalanceReport(balances)]); + } + } + + getReportAtStage(stage: ReportingStage, reportType: any): Report { + // TODO: This function needs generics + const reportsForTheStage = this.reportsForStage.get(stage); + if (!reportsForTheStage) { + throw new Error('Attempt to get report for unavailable stage'); + } + + const report = reportsForTheStage.find((r) => r instanceof reportType); + if (!report) { + throw new Error('Report does not exist'); + } + + return report; + } + + getTransactionsAtStage(stage: ReportingStage): Transaction[] { + const transactions: Transaction[] = []; + for (const [curStage, curTransactions] of this.transactionsForStage.entries()) { + if (curStage <= stage) { + transactions.push(...curTransactions); + } + } + return transactions; + } +} + +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()]); + + // Apply transactions + for (const transaction of transactions) { + for (const posting of transaction.postings) { + const openingBalance = newBalances.get(posting.account) ?? 0; + const quantityCost = asCost(posting.quantity, posting.commodity); + const runningBalance = openingBalance + quantityCost; + + newBalances.set(posting.account, runningBalance); + } + } + + // Sort accounts + return new Map([...newBalances.entries()].sort((a, b) => a[0].localeCompare(b[0]))); +}