Implement formal BalanceSheet report

This commit is contained in:
RunasSudo 2025-05-24 21:07:18 +10:00
parent eb13bd5a87
commit 737ed5bfb2
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
6 changed files with 574 additions and 17 deletions

View File

@ -93,7 +93,7 @@ impl DbConnection {
async fn get_account_configurations_async(&self) -> Vec<AccountConfiguration> {
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
}
}

View File

@ -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 {
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 {
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 {
name: "BalanceSheet",
kind: ReportingProductKind::Generic,
args: Box::new(MultipleDateArgs {
dates: vec![DateArgs {
date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
}],
}),
})
.unwrap();

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>,
pub entries: Vec<DynamicReportEntry>,
}
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) = &section.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<QuantityInt> {
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<String>,
pub visible: bool,
pub auto_hide: bool,
pub entries: Vec<DynamicReportEntry>,
}
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) = &section.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<QuantityInt> {
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<QuantityInt>,
pub id: Option<String>,
pub visible: bool,
pub auto_hide: bool,
pub link: Option<String>,
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<String>,
//pub visible: bool,
//pub auto_hide: bool,
//pub link: Option<String>,
//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<String, QuantityInt>>,
kinds_for_account: &HashMap<String, Vec<String>>,
) -> Vec<DynamicReportEntry> {
// 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::<Vec<_>>();
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::<Vec<_>>();
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
}

View File

@ -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;

View File

@ -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<dyn ReportingStepArgs>) -> bool {
args.is::<MultipleDateArgs>()
}
fn from_args(args: Box<dyn ReportingStepArgs>) -> Box<dyn ReportingStep> {
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<ReportingProductId> {
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<Box<dyn ReportingStep>>,
_dependencies: &ReportingGraphDependencies,
products: &mut ReportingProducts,
) -> Result<(), ReportingExecutionError> {
// Get balances for each period
let mut balances: Vec<&HashMap<String, QuantityInt>> = 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::<BalancesAt>().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 {}

View File

@ -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<DateArgs>,
}
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::<Vec<_>>()
.join(", ")
))
}
}
/// [ReportingStepArgs] implementation which takes multiple [DateStartDateEndArgs]
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct MultipleDateStartDateEndArgs {
pub dates: Vec<DateStartDateEndArgs>,
}
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::<Vec<_>>()
.join(", ")
))
}
}