/* 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 <https://www.gnu.org/licenses/>. */ import { asCost } from './amounts.ts'; import { JoinedTransactionPosting, StatementLine, Transaction, joinedToTransactions, totalBalances, totalBalancesAtDate } 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<ReportingStage, Transaction[]> = new Map(); reportsForStage: Map<ReportingStage, Report[]> = new Map(); async generate(session: ExtendedDatabase, dt?: string) { // ------------------------ // TransactionsFromDatabase let balances: Map<string, number>; { // Load balances from database if (dt) { balances = await totalBalancesAtDate(session, dt); } else { balances = await totalBalances(session); } this.reportsForStage.set(ReportingStage.TransactionsFromDatabase, [new TrialBalanceReport(balances)]); // Load transactions from database let joinedTransactionPostings: JoinedTransactionPosting[]; if (dt) { joinedTransactionPostings = 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 DATE(dt) <= DATE($1) ORDER BY dt, transaction_id, postings.id`, [dt] ); } else { joinedTransactionPostings = 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 let unreconciledStatementLines: StatementLine[]; if (dt) { unreconciledStatementLines = 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 AND DATE(dt) <= DATE($1)`, [dt] ); } else { unreconciledStatementLines = await session.select( `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<string, number> ) {} } function applyTransactionsToBalances(balances: Map<string, number>, transactions: Transaction[]): Map<string, number> { // Initialise new balances const newBalances: Map<string, number> = 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]))); }