diff --git a/src/reporting/dynamic_report.rs b/src/reporting/dynamic_report.rs index 7b036d8..d1b25a6 100644 --- a/src/reporting/dynamic_report.rs +++ b/src/reporting/dynamic_report.rs @@ -103,25 +103,25 @@ impl DynamicReport { for entry in self.entries.iter() { match entry.try_borrow() { Ok(entry) => match &*entry { - DynamicReportEntry::Section(section) => { - if let Some(i) = §ion.id { - if i == id { + DynamicReportEntry::Section(section) => { + if let Some(i) = §ion.id { + if i == id { return Some(entry.clone()); + } + } + if let Some(e) = section.by_id(id) { + return Some(e); } } - if let Some(e) = section.by_id(id) { - return Some(e); - } - } - DynamicReportEntry::LiteralRow(row) => { - if let Some(i) = &row.id { - if i == id { + DynamicReportEntry::LiteralRow(row) => { + if let Some(i) = &row.id { + if i == id { return Some(entry.clone()); + } } } - } - DynamicReportEntry::CalculatedRow(_) => (), - DynamicReportEntry::Spacer => (), + DynamicReportEntry::CalculatedRow(_) => (), + DynamicReportEntry::Spacer => (), }, Err(err) => panic!( "Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}", @@ -143,6 +143,16 @@ impl DynamicReport { } } + // Return the quantities for the [LiteralRow] with the given id + pub fn quantity_for_id(&self, id: &str) -> Vec { + let entry = self.by_id(id).expect("Invalid id"); + if let DynamicReportEntry::LiteralRow(row) = entry { + row.quantity + } else { + panic!("Called quantity_for_id on non-LiteralRow"); + } + } + /// Serialise the report (as JSON) using serde pub fn to_json(&self) -> String { serde_json::to_string(self).unwrap() @@ -256,25 +266,25 @@ impl Section { for entry in self.entries.iter() { match entry.try_borrow() { Ok(entry) => match &*entry { - DynamicReportEntry::Section(section) => { - if let Some(i) = §ion.id { - if i == id { + DynamicReportEntry::Section(section) => { + if let Some(i) = §ion.id { + if i == id { return Some(entry.clone()); + } + } + if let Some(e) = section.by_id(id) { + return Some(e); } } - if let Some(e) = section.by_id(id) { - return Some(e); - } - } - DynamicReportEntry::LiteralRow(row) => { - if let Some(i) = &row.id { - if i == id { + DynamicReportEntry::LiteralRow(row) => { + if let Some(i) = &row.id { + if i == id { return Some(entry.clone()); + } } } - } - DynamicReportEntry::CalculatedRow(_) => (), - DynamicReportEntry::Spacer => (), + DynamicReportEntry::CalculatedRow(_) => (), + DynamicReportEntry::Spacer => (), }, Err(err) => panic!( "Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}", diff --git a/src/reporting/steps.rs b/src/reporting/steps.rs index b5a2f70..7febc34 100644 --- a/src/reporting/steps.rs +++ b/src/reporting/steps.rs @@ -37,8 +37,9 @@ use super::dynamic_report::{ }; use super::executor::ReportingExecutionError; use super::types::{ - BalancesBetween, DateArgs, MultipleDateArgs, ReportingContext, ReportingProductKind, - ReportingProducts, ReportingStep, ReportingStepArgs, ReportingStepId, VoidArgs, + BalancesBetween, DateArgs, MultipleDateArgs, MultipleDateStartDateEndArgs, ReportingContext, + ReportingProductKind, ReportingProducts, ReportingStep, ReportingStepArgs, ReportingStepId, + VoidArgs, }; /// Call [ReportingContext::register_lookup_fn] for all steps provided by this module @@ -50,6 +51,7 @@ pub fn register_lookup_fns(context: &mut ReportingContext) { CombineOrdinaryTransactions::register_lookup_fn(context); CurrentYearEarningsToEquity::register_lookup_fn(context); DBBalances::register_lookup_fn(context); + IncomeStatement::register_lookup_fn(context); PostUnreconciledStatementLines::register_lookup_fn(context); RetainedEarningsToEquity::register_lookup_fn(context); } @@ -867,6 +869,180 @@ impl ReportingStep for DBBalances { } } +/// Generates an income statement [DynamicReport] +#[derive(Debug)] +pub struct IncomeStatement { + pub args: MultipleDateStartDateEndArgs, +} + +impl IncomeStatement { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "IncomeStatement", + &[ReportingProductKind::Generic], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(IncomeStatement { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for IncomeStatement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +impl ReportingStep for IncomeStatement { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "IncomeStatement", + product_kinds: &[ReportingProductKind::Generic], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + let mut result = Vec::new(); + + // IncomeStatement depends on AllTransactionsExceptEarningsToEquity in each requested period + for date_args in self.args.dates.iter() { + result.push(ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesBetween, + 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: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::BalancesBetween, + args: Box::new(date_args.clone()), + })?; + + balances.push(&product.downcast_ref::().unwrap().balances); + } + + // Get names of all income statement accounts + let kinds_for_account = + kinds_for_account(context.db_connection.get_account_configurations()); + + // Init report + let mut report = DynamicReport::new( + "Income statement".to_string(), + self.args + .dates + .iter() + .map(|d| d.date_end.to_string()) + .collect(), + vec![ + DynamicReportEntry::Section(Section::new( + "Income".to_string(), + Some("income".to_string()), + true, + false, + { + let mut entries = + entries_for_kind("drcr.income", true, &balances, &kinds_for_account); + entries.push(DynamicReportEntry::CalculatedRow(CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total income".to_string(), + quantity: report.subtotal_for_id("income"), + id: Some("total_income".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + })); + entries + }, + )), + DynamicReportEntry::Spacer, + DynamicReportEntry::Section(Section::new( + "Expenses".to_string(), + Some("expenses".to_string()), + true, + false, + { + let mut entries = + entries_for_kind("drcr.expense", false, &balances, &kinds_for_account); + entries.push(DynamicReportEntry::CalculatedRow(CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Total expenses".to_string(), + quantity: report.subtotal_for_id("expenses"), + id: Some("total_expenses".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + })); + entries + }, + )), + DynamicReportEntry::Spacer, + DynamicReportEntry::CalculatedRow(CalculatedRow { + calculate_fn: |report| LiteralRow { + text: "Net surplus (deficit)".to_string(), + quantity: report + .quantity_for_id("total_income") // Get total income row + .iter() + .zip(report.quantity_for_id("total_expenses").iter()) // Zip with total expenses row + .map(|(i, e)| i - e) // Compute net surplus + .collect(), + id: Some("net_surplus".to_string()), + visible: true, + auto_hide: false, + link: None, + heading: true, + bordered: true, + }, + }), + ], + ); + + report.calculate(); + report.auto_hide(); + + // Store the result + products.insert( + ReportingProductId { + name: "IncomeStatement", + kind: ReportingProductKind::Generic, + args: Box::new(self.args.clone()), + }, + Box::new(report), + ); + + Ok(()) + } +} + /// Generate transactions for unreconciled statement lines #[derive(Debug)] pub struct PostUnreconciledStatementLines {