diff --git a/src/db.rs b/src/db.rs index 6c39107..0e533fa 100644 --- a/src/db.rs +++ b/src/db.rs @@ -93,7 +93,7 @@ impl DbConnection { async fn get_account_configurations_async(&self) -> Vec { let mut connection = self.sqlx_connection.borrow_mut(); - let account_configurations = + let mut account_configurations = sqlx::query("SELECT id, account, kind, data FROM account_configurations") .map(|r: SqliteRow| AccountConfiguration { id: r.get("id"), @@ -105,6 +105,20 @@ impl DbConnection { .await .expect("SQL error"); + // System accounts + account_configurations.push(AccountConfiguration { + id: None, + account: "Current Year Earnings".to_string(), + kind: "drcr.equity".to_string(), + data: None, + }); + account_configurations.push(AccountConfiguration { + id: None, + account: "Retained Earnings".to_string(), + kind: "drcr.equity".to_string(), + data: None, + }); + account_configurations } } diff --git a/src/main.rs b/src/main.rs index d169050..9e011d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,8 +23,8 @@ use libdrcr::reporting::calculator::{steps_as_graphviz, steps_for_targets}; use libdrcr::reporting::generate_report; use libdrcr::reporting::steps::register_lookup_fns; use libdrcr::reporting::types::{ - DateArgs, DateStartDateEndArgs, ReportingContext, ReportingProductId, ReportingProductKind, - VoidArgs, + DateArgs, DateStartDateEndArgs, MultipleDateArgs, ReportingContext, ReportingProductId, + ReportingProductKind, VoidArgs, }; fn main() { @@ -50,10 +50,12 @@ fn main() { args: Box::new(VoidArgs {}), }, ReportingProductId { - name: "AllTransactionsIncludingEarningsToEquity", - kind: ReportingProductKind::BalancesAt, - args: Box::new(DateArgs { - date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + name: "BalanceSheet", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateArgs { + dates: vec![DateArgs { + date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + }], }), }, ]; @@ -104,10 +106,12 @@ fn main() { args: Box::new(VoidArgs {}), }, ReportingProductId { - name: "AllTransactionsIncludingEarningsToEquity", - kind: ReportingProductKind::BalancesAt, - args: Box::new(DateArgs { - date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + name: "BalanceSheet", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateArgs { + dates: vec![DateArgs { + date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + }], }), }, ]; @@ -115,10 +119,12 @@ fn main() { let products = generate_report(targets, &context).unwrap(); let result = products .get_or_err(&ReportingProductId { - name: "AllTransactionsIncludingEarningsToEquity", - kind: ReportingProductKind::BalancesAt, - args: Box::new(DateArgs { - date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + name: "BalanceSheet", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateArgs { + dates: vec![DateArgs { + date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(), + }], }), }) .unwrap(); diff --git a/src/reporting/dynamic_report.rs b/src/reporting/dynamic_report.rs new file mode 100644 index 0000000..5ba208a --- /dev/null +++ b/src/reporting/dynamic_report.rs @@ -0,0 +1,311 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 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 . +*/ + +use std::collections::HashMap; + +use crate::QuantityInt; + +use super::types::{GenericReportingProduct, ReportingProduct}; + +/// Represents a dynamically generated report composed of [DynamicReportEntry] +#[derive(Clone, Debug)] +pub struct DynamicReport { + pub title: String, + pub columns: Vec, + pub entries: Vec, +} + +impl DynamicReport { + /// Remove all entries from the report where auto_hide is enabled and quantity is zero + pub fn auto_hide(&mut self) { + self.entries.retain_mut(|e| match e { + DynamicReportEntry::Section(section) => { + section.auto_hide_children(); + if section.can_auto_hide_self() { + false + } else { + true + } + } + DynamicReportEntry::LiteralRow(row) => { + if row.can_auto_hide() { + false + } else { + true + } + } + DynamicReportEntry::CalculatedRow(_) => true, + DynamicReportEntry::Spacer => true, + }); + } + + /// Recursively calculate all [CalculatedRow] entries + pub fn calculate(&mut self) { + // FIXME: This is for the borrow checker - can it be avoided? + let report_cloned = self.clone(); + + for entry in self.entries.iter_mut() { + match entry { + DynamicReportEntry::Section(section) => section.calculate(&report_cloned), + DynamicReportEntry::LiteralRow(_) => (), + DynamicReportEntry::CalculatedRow(row) => { + *entry = DynamicReportEntry::LiteralRow(row.calculate(&report_cloned)); + } + DynamicReportEntry::Spacer => (), + } + } + } + + /// Look up [DynamicReportEntry] by id + pub fn by_id(&self, id: &str) -> Option<&DynamicReportEntry> { + for entry in self.entries.iter() { + match entry { + DynamicReportEntry::Section(section) => { + if let Some(i) = §ion.id { + if i == id { + return Some(entry); + } + } + if let Some(e) = section.by_id(id) { + return Some(e); + } + } + DynamicReportEntry::LiteralRow(row) => { + if let Some(i) = &row.id { + if i == id { + return Some(entry); + } + } + } + DynamicReportEntry::CalculatedRow(_) => (), + DynamicReportEntry::Spacer => (), + } + } + + None + } + + /// Calculate the subtotals for the [Section] with the given id + pub fn subtotal_for_id(&self, id: &str) -> Vec { + let entry = self.by_id(id).expect("Invalid id"); + if let DynamicReportEntry::Section(section) = entry { + section.subtotal(&self) + } else { + panic!("Called subtotal_for_id on non-Section"); + } + } +} + +impl GenericReportingProduct for DynamicReport {} +impl ReportingProduct for DynamicReport {} + +#[derive(Clone, Debug)] +pub enum DynamicReportEntry { + Section(Section), + LiteralRow(LiteralRow), + CalculatedRow(CalculatedRow), + Spacer, +} + +#[derive(Clone, Debug)] +pub struct Section { + pub text: String, + pub id: Option, + pub visible: bool, + pub auto_hide: bool, + pub entries: Vec, +} + +impl Section { + fn auto_hide_children(&mut self) { + self.entries.retain_mut(|e| match e { + DynamicReportEntry::Section(section) => { + section.auto_hide_children(); + if section.can_auto_hide_self() { + false + } else { + true + } + } + DynamicReportEntry::LiteralRow(row) => { + if row.can_auto_hide() { + false + } else { + true + } + } + DynamicReportEntry::CalculatedRow(_) => true, + DynamicReportEntry::Spacer => true, + }); + } + + fn can_auto_hide_self(&self) -> bool { + self.auto_hide + && self.entries.iter().all(|e| match e { + DynamicReportEntry::Section(section) => section.can_auto_hide_self(), + DynamicReportEntry::LiteralRow(row) => row.can_auto_hide(), + DynamicReportEntry::CalculatedRow(_) => false, + DynamicReportEntry::Spacer => true, + }) + } + + /// Recursively calculate all [CalculatedRow] entries + pub fn calculate(&mut self, report: &DynamicReport) { + for entry in self.entries.iter_mut() { + match entry { + DynamicReportEntry::Section(section) => section.calculate(report), + DynamicReportEntry::LiteralRow(_) => (), + DynamicReportEntry::CalculatedRow(row) => { + *entry = DynamicReportEntry::LiteralRow(row.calculate(report)) + } + DynamicReportEntry::Spacer => (), + } + } + } + + /// Look up [DynamicReportEntry] by id + pub fn by_id(&self, id: &str) -> Option<&DynamicReportEntry> { + for entry in self.entries.iter() { + match entry { + DynamicReportEntry::Section(section) => { + if let Some(i) = §ion.id { + if i == id { + return Some(entry); + } + } + if let Some(e) = section.by_id(id) { + return Some(e); + } + } + DynamicReportEntry::LiteralRow(row) => { + if let Some(i) = &row.id { + if i == id { + return Some(entry); + } + } + } + DynamicReportEntry::CalculatedRow(_) => (), + DynamicReportEntry::Spacer => (), + } + } + + None + } + + /// Calculate the subtotals for this [Section] + pub fn subtotal(&self, report: &DynamicReport) -> Vec { + let mut subtotals = vec![0; report.columns.len()]; + for entry in self.entries.iter() { + match entry { + DynamicReportEntry::Section(section) => { + for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() { + subtotals[col_idx] += subtotal; + } + } + DynamicReportEntry::LiteralRow(row) => { + for (col_idx, subtotal) in row.quantity.iter().enumerate() { + subtotals[col_idx] += subtotal; + } + } + DynamicReportEntry::CalculatedRow(_) => (), + DynamicReportEntry::Spacer => (), + } + } + subtotals + } +} + +#[derive(Clone, Debug)] +pub struct LiteralRow { + pub text: String, + pub quantity: Vec, + pub id: Option, + pub visible: bool, + pub auto_hide: bool, + pub link: Option, + pub heading: bool, + pub bordered: bool, +} + +impl LiteralRow { + /// Returns whether the row has auto_hide enabled and all quantities are zero + fn can_auto_hide(&self) -> bool { + self.auto_hide && self.quantity.iter().all(|q| *q == 0) + } +} + +#[derive(Clone, Debug)] +pub struct CalculatedRow { + //pub text: String, + pub calculate_fn: fn(report: &DynamicReport) -> LiteralRow, + //pub id: Option, + //pub visible: bool, + //pub auto_hide: bool, + //pub link: Option, + //pub heading: bool, + //pub bordered: bool, +} + +impl CalculatedRow { + fn calculate(&self, report: &DynamicReport) -> LiteralRow { + (self.calculate_fn)(report) + } +} + +pub fn entries_for_kind( + kind: &str, + invert: bool, + balances: &Vec<&HashMap>, + kinds_for_account: &HashMap>, +) -> Vec { + // Get accounts of specified kind + let mut accounts = kinds_for_account + .iter() + .filter_map(|(a, k)| { + if k.iter().any(|k| k == kind) { + Some(a) + } else { + None + } + }) + .collect::>(); + + accounts.sort(); + + let mut entries = Vec::new(); + for account in accounts { + let quantities = balances + .iter() + .map(|b| b.get(account).unwrap_or(&0) * if invert { -1 } else { 1 }) + .collect::>(); + + let entry = LiteralRow { + text: account.to_string(), + quantity: quantities, + id: None, + visible: true, + auto_hide: true, + link: None, + heading: false, + bordered: false, + }; + entries.push(DynamicReportEntry::LiteralRow(entry)); + } + + entries +} diff --git a/src/reporting/mod.rs b/src/reporting/mod.rs index aabe51c..1ea2e13 100644 --- a/src/reporting/mod.rs +++ b/src/reporting/mod.rs @@ -22,6 +22,7 @@ use types::{ReportingContext, ReportingProductId, ReportingProducts}; pub mod builders; pub mod calculator; +pub mod dynamic_report; pub mod executor; pub mod steps; pub mod types; diff --git a/src/reporting/steps.rs b/src/reporting/steps.rs index 7a19a44..0c2977d 100644 --- a/src/reporting/steps.rs +++ b/src/reporting/steps.rs @@ -29,18 +29,24 @@ use crate::transaction::{ update_balances_from_transactions, Posting, Transaction, TransactionWithPostings, }; use crate::util::sofy_from_eofy; +use crate::QuantityInt; use super::calculator::ReportingGraphDependencies; +use super::dynamic_report::{ + entries_for_kind, CalculatedRow, DynamicReport, DynamicReportEntry, LiteralRow, Section, +}; use super::executor::ReportingExecutionError; use super::types::{ - BalancesBetween, DateArgs, ReportingContext, ReportingProduct, ReportingProductKind, - ReportingProducts, ReportingStep, ReportingStepArgs, ReportingStepId, VoidArgs, + BalancesBetween, DateArgs, MultipleDateArgs, ReportingContext, ReportingProduct, + ReportingProductKind, ReportingProducts, ReportingStep, ReportingStepArgs, ReportingStepId, + VoidArgs, }; /// Call [ReportingContext::register_lookup_fn] for all steps provided by this module pub fn register_lookup_fns(context: &mut ReportingContext) { AllTransactionsExceptEarningsToEquity::register_lookup_fn(context); AllTransactionsIncludingEarningsToEquity::register_lookup_fn(context); + BalanceSheet::register_lookup_fn(context); CalculateIncomeTax::register_lookup_fn(context); CombineOrdinaryTransactions::register_lookup_fn(context); CurrentYearEarningsToEquity::register_lookup_fn(context); @@ -296,6 +302,182 @@ impl ReportingStep for AllTransactionsIncludingEarningsToEquity { } } +/// Generates a balance sheet [DynamicReport] +#[derive(Debug)] +pub struct BalanceSheet { + pub args: MultipleDateArgs, +} + +impl BalanceSheet { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "BalanceSheet", + &[ReportingProductKind::Generic], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(BalanceSheet { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for BalanceSheet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +impl ReportingStep for BalanceSheet { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "BalanceSheet", + product_kinds: &[ReportingProductKind::Generic], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + let mut result = Vec::new(); + + // BalanceSheet depends on AllTransactionsIncludingEarningsToEquity in each requested period + for date_args in self.args.dates.iter() { + result.push(ReportingProductId { + name: "AllTransactionsIncludingEarningsToEquity", + kind: ReportingProductKind::BalancesAt, + args: Box::new(date_args.clone()), + }); + } + + result + } + + fn execute( + &self, + context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + products: &mut ReportingProducts, + ) -> Result<(), ReportingExecutionError> { + // Get balances for each period + let mut balances: Vec<&HashMap> = Vec::new(); + for date_args in self.args.dates.iter() { + let product = products.get_or_err(&ReportingProductId { + name: "AllTransactionsIncludingEarningsToEquity", + kind: ReportingProductKind::BalancesAt, + args: Box::new(date_args.clone()), + })?; + + balances.push(&product.downcast_ref::().unwrap().balances); + } + + // Get names of all balance sheet accounts + let kinds_for_account = + kinds_for_account(context.db_connection.get_account_configurations()); + + // Init report + let mut report = DynamicReport { + title: "Balance sheet".to_string(), + columns: self.args.dates.iter().map(|d| d.date.to_string()).collect(), + entries: vec![ + DynamicReportEntry::Section(Section { + text: "Assets".to_string(), + id: Some("assets".to_string()), + visible: true, + auto_hide: false, + entries: { + let mut entries = + entries_for_kind("drcr.asset", false, &balances, &kinds_for_account); + entries.push(DynamicReportEntry::CalculatedRow(CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total assets".to_string(), + quantity: report.subtotal_for_id("assets"), + id: None, + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + })); + entries + }, + }), + DynamicReportEntry::Spacer, + DynamicReportEntry::Section(Section { + text: "Liabilities".to_string(), + id: Some("liabilities".to_string()), + visible: true, + auto_hide: false, + entries: { + let mut entries = + entries_for_kind("drcr.liability", true, &balances, &kinds_for_account); + entries.push(DynamicReportEntry::CalculatedRow(CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total liabilities".to_string(), + quantity: report.subtotal_for_id("liabilities"), + id: None, + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + })); + entries + }, + }), + DynamicReportEntry::Spacer, + DynamicReportEntry::Section(Section { + text: "Equity".to_string(), + id: Some("equity".to_string()), + visible: true, + auto_hide: false, + entries: { + let mut entries = + entries_for_kind("drcr.equity", true, &balances, &kinds_for_account); + entries.push(DynamicReportEntry::CalculatedRow(CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total equity".to_string(), + quantity: report.subtotal_for_id("equity"), + id: None, + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + })); + entries + }, + }), + ], + }; + + report.calculate(); + report.auto_hide(); + + // Store the result + products.insert( + ReportingProductId { + name: "BalanceSheet", + kind: ReportingProductKind::Generic, + args: Box::new(self.args.clone()), + }, + Box::new(report), + ); + + Ok(()) + } +} + /// Calculates income tax #[derive(Debug)] pub struct CalculateIncomeTax {} diff --git a/src/reporting/types.rs b/src/reporting/types.rs index c78547b..27e89f2 100644 --- a/src/reporting/types.rs +++ b/src/reporting/types.rs @@ -18,6 +18,7 @@ use std::collections::HashMap; use std::fmt::{Debug, Display}; +use std::hash::Hash; use chrono::NaiveDate; use downcast_rs::Downcast; @@ -356,3 +357,45 @@ impl Display for DateStartDateEndArgs { f.write_fmt(format_args!("{}, {}", self.date_start, self.date_end)) } } + +/// [ReportingStepArgs] implementation which takes multiple [DateArgs] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct MultipleDateArgs { + pub dates: Vec, +} + +impl ReportingStepArgs for MultipleDateArgs {} + +impl Display for MultipleDateArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{}", + self.dates + .iter() + .map(|a| a.to_string()) + .collect::>() + .join(", ") + )) + } +} + +/// [ReportingStepArgs] implementation which takes multiple [DateStartDateEndArgs] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct MultipleDateStartDateEndArgs { + pub dates: Vec, +} + +impl ReportingStepArgs for MultipleDateStartDateEndArgs {} + +impl Display for MultipleDateStartDateEndArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "{}", + self.dates + .iter() + .map(|a| format!("({})", a)) + .collect::>() + .join(", ") + )) + } +}