Compare commits

...

58 Commits

Author SHA1 Message Date
0b3770fb95 Add 'libdrcr/' from commit '364c44d60a3cc0ee93ed52925aa5f1109636f93c'
git-subtree-dir: libdrcr
git-subtree-mainline: 4364af9b9a67a88a35da031a76d1a335af36c30b
git-subtree-split: 364c44d60a3cc0ee93ed52925aa5f1109636f93c
2025-05-28 00:37:35 +10:00
364c44d60a
Restore account links to dynamic reports 2025-05-28 00:33:54 +10:00
4364af9b9a
Account transactions view using libdrcr 2025-05-28 00:26:12 +10:00
20773c4640
Implement getting balance assertions from database 2025-05-28 00:25:47 +10:00
ffef2d16dc
Balance assertions view using libdrcr 2025-05-28 00:16:09 +10:00
bbcb3cee6f
Remove defineModel, defineProps imports
This silences a Vue compiler warning
2025-05-27 23:50:11 +10:00
ad3276bbd5
General ledger report using libdrcr 2025-05-27 23:49:34 +10:00
233c6d6aa9
Implement getting transactions from database 2025-05-27 23:48:40 +10:00
51a40e5ed9
Trial balance using libdrcr 2025-05-27 22:21:06 +10:00
c9c3bc0d2c
Implement trial balance report 2025-05-27 22:19:36 +10:00
b938176b5f
Implement PostUnreconciledStatementLines 2025-05-27 18:26:13 +10:00
dfdd3b0924
Only calculate YTD figures in CurrentEarningsToEquity 2025-05-27 17:36:39 +10:00
930213c461
Fix Spacer being dropped when DynamicReport is calculated 2025-05-27 17:31:11 +10:00
c3a407b048
Execute reporting steps in parallel in libdrcr 2025-05-27 17:29:04 +10:00
1b67df61be
Execute reporting steps in parallel 2025-05-27 17:28:34 +10:00
53497e7593
Fix UpdateBalancesBetween using incorrect period 2025-05-27 17:28:22 +10:00
835af70bc7
Sanity check reporting products 2025-05-27 16:16:15 +10:00
4ff0ea46db
Use libdrcr async API 2025-05-27 16:03:12 +10:00
706d26e54f
Make reporting API async 2025-05-27 16:02:28 +10:00
af47021e4f
Fix TypeScript errors 2025-05-27 01:14:25 +10:00
42ba33c45c
Look up eofy_date in libdrcr_bridge itself 2025-05-27 00:54:22 +10:00
df8ec39e1e
Cache database metadata 2025-05-27 00:53:26 +10:00
807316a090
Implement income statement report using libdrcr 2025-05-27 00:22:52 +10:00
a967c87dab
Implement full balance sheet features using libdrcr 2025-05-27 00:22:22 +10:00
5430c6713f
Implement IncomeStatement step 2025-05-27 00:21:30 +10:00
d44c2a1200
Refactor DynamicReport to use RefCell<DynamicReportEntry>
Allows calculations to refer to the results of previous calculations
Rather than the same cloned DynamicReport being passed to all calculations
2025-05-27 00:21:17 +10:00
42eaa015bd
Add ids for total rows in balance sheet 2025-05-26 23:07:18 +10:00
9bb9eaabaf
Fix multiple logic errors when reporting for not current year 2025-05-26 22:43:59 +10:00
25697b501c
Basic implementation of balance sheet report using libdrcr 2025-05-26 21:42:45 +10:00
faa53c625c
Implement JSON serialisation for DynamicReport 2025-05-25 01:23:35 +10:00
1dcb31df57
Update documentation 2025-05-25 01:20:37 +10:00
f3ad696168
Remove unused dependency from Cargo.toml 2025-05-25 01:20:30 +10:00
f76d2a5736
Implement formal BalanceSheet report 2025-05-24 21:07:18 +10:00
fed7def6f3
CurrentYearEarningsToEquity depends on AllTransactionsExceptEarningsToEquity 2025-05-24 14:32:01 +10:00
34fd8233cf
Refactor steps_for_targets to accept Vec<ReportingProductId> 2025-05-24 01:01:03 +10:00
8f1903e532
Refactor build_step_for_product into standalone function
Prepare for changing steps_for_targets to take list of products.
2025-05-24 00:51:24 +10:00
407974e440
Validate dynamic builder outputs 2025-05-24 00:43:40 +10:00
35d397f5c9
Add function to visualise dependency tree via graphviz 2025-05-24 00:40:27 +10:00
38014b7c91
Implement CurrentYearEarningsToEquity 2025-05-24 00:10:37 +10:00
4ba1317fce
Implement RetainedEarningsToEquity 2025-05-23 23:54:36 +10:00
9fe7bf22a6
Basic implementation of DBBalances 2025-05-22 00:26:29 +10:00
412b79ee45
Statically require single member for AllTransactionsExceptRetainedEarnings.product_kinds 2025-05-21 22:29:18 +10:00
4e94557370
Update documentation 2025-05-21 22:26:40 +10:00
798c7d3c07
Refactor update_balances_from_transactions 2025-05-21 21:53:35 +10:00
bfb41d8d15
Stub implementations for all steps 2025-05-21 21:48:57 +10:00
7f188db677
Refactor register_lookup_fns and register_dynamic_builders for readability 2025-05-21 20:22:06 +10:00
0f8e3e5d4a
Basic framework for executing reports 2025-05-21 20:15:18 +10:00
ae26b64d5e
Refactoring and documentation 2025-05-21 19:59:57 +10:00
37e9e19c5e
Rename Dependency.dependency to Dependency.product 2025-05-21 19:18:14 +10:00
58758b0cb3
Implement RetainedEarningsToEquity 2025-05-21 18:24:59 +10:00
349ecf3d76
Fix off by one error in BalancesAtToBalancesBetween 2025-05-21 18:24:29 +10:00
1e33074b4d
Refactor AllTransactionsIncludingRetainedEarnings 2025-05-21 18:20:19 +10:00
161acabb7d
Refactor CalculateIncomeTax 2025-05-21 18:10:08 +10:00
5a1b54f782
Implement UpdateBalancesAt 2025-05-21 17:58:42 +10:00
61ed6f82d7
Implement Display for ReportingStep 2025-05-21 17:16:25 +10:00
39617a54ac
Implement GenerateBalances dynamic builder 2025-05-21 17:11:20 +10:00
de890aeade
Refactor representation of ReportingStep args 2025-05-21 16:39:18 +10:00
0ee500af3e
Basic dependency resolution code 2025-05-21 00:39:54 +10:00
46 changed files with 7628 additions and 1063 deletions

1
libdrcr/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1987
libdrcr/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
libdrcr/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "libdrcr"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1.88"
chrono = "0.4.41"
downcast-rs = "2.0.1"
dyn-clone = "1.0.19"
dyn-eq = "0.1.3"
dyn-hash = "0.2.2"
indexmap = "2.9.0"
serde = "1.0.219"
serde_json = "1.0.140"
sqlx = { version = "0.8", features = [ "runtime-tokio", "sqlite" ] }
tokio = { version = "1.45.0", features = ["full"] }

BIN
libdrcr/drcr_testing.db Normal file

Binary file not shown.

1
libdrcr/rustfmt.toml Normal file
View File

@ -0,0 +1 @@
hard_tabs = true

View File

@ -0,0 +1,43 @@
/*
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;
pub struct AccountConfiguration {
pub id: Option<u64>,
pub account: String,
pub kind: String,
pub data: Option<String>,
}
/// Convert [`Vec<AccountConfiguration>`] into a [HashMap] mapping account names to account kinds
pub fn kinds_for_account(
account_configurations: Vec<AccountConfiguration>,
) -> HashMap<String, Vec<String>> {
let mut result = HashMap::new();
for account_configuration in account_configurations {
// Record the account kind
result
.entry(account_configuration.account)
.or_insert_with(|| Vec::new())
.push(account_configuration.kind);
}
result
}

266
libdrcr/src/db.rs Normal file
View File

@ -0,0 +1,266 @@
/*
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 chrono::{NaiveDate, NaiveDateTime};
use sqlx::sqlite::SqliteRow;
use sqlx::{Connection, Row, SqliteConnection};
use crate::account_config::AccountConfiguration;
use crate::model::assertions::BalanceAssertion;
use crate::model::statements::StatementLine;
use crate::model::transaction::{Posting, Transaction, TransactionWithPostings};
use crate::{util::format_date, QuantityInt};
pub struct DbConnection {
url: String,
metadata: DbMetadata,
}
impl DbConnection {
pub async fn new(url: &str) -> Self {
let mut connection = SqliteConnection::connect(url).await.expect("SQL error");
let metadata = DbMetadata::from_database(&mut connection).await;
Self {
url: url.to_string(),
metadata,
}
}
pub fn metadata(&self) -> &DbMetadata {
&self.metadata
}
pub async fn connect(&self) -> SqliteConnection {
SqliteConnection::connect(&self.url)
.await
.expect("SQL error")
}
/// Get account configurations from the database
pub async fn get_account_configurations(&self) -> Vec<AccountConfiguration> {
let mut connection = self.connect().await;
let mut account_configurations =
sqlx::query("SELECT id, account, kind, data FROM account_configurations")
.map(|r: SqliteRow| AccountConfiguration {
id: r.get("id"),
account: r.get("account"),
kind: r.get("kind"),
data: r.get("data"),
})
.fetch_all(&mut connection)
.await
.expect("SQL error");
// System accounts
account_configurations.push(AccountConfiguration {
id: None,
account: crate::CURRENT_YEAR_EARNINGS.to_string(),
kind: "drcr.equity".to_string(),
data: None,
});
account_configurations.push(AccountConfiguration {
id: None,
account: crate::RETAINED_EARNINGS.to_string(),
kind: "drcr.equity".to_string(),
data: None,
});
account_configurations
}
/// Get balance assertions from the database
pub async fn get_balance_assertions(&self) -> Vec<BalanceAssertion> {
let mut connection = self.connect().await;
let balance_assertions = sqlx::query(
"SELECT id, dt, description, account, quantity, commodity
FROM balance_assertions
ORDER BY dt DESC, id DESC",
)
.map(|r: SqliteRow| BalanceAssertion {
id: r.get("id"),
dt: NaiveDateTime::parse_from_str(r.get("dt"), "%Y-%m-%d %H:%M:%S.%6f")
.expect("Invalid balance_assertions.dt"),
description: r.get("description"),
account: r.get("account"),
quantity: r.get("quantity"),
commodity: r.get("commodity"),
})
.fetch_all(&mut connection)
.await
.expect("SQL error");
balance_assertions
}
/// Get account balances from the database
pub async fn get_balances(&self, date: NaiveDate) -> HashMap<String, QuantityInt> {
let mut connection = self.connect().await;
let rows = sqlx::query(
"-- Get last transaction for each account
WITH max_dt_by_account AS (
SELECT account, max(dt) AS max_dt
FROM joined_transactions
WHERE DATE(dt) <= DATE($1)
GROUP BY account
),
max_tid_by_account AS (
SELECT max_dt_by_account.account, max(transaction_id) AS max_tid
FROM max_dt_by_account
JOIN joined_transactions ON max_dt_by_account.account = joined_transactions.account AND max_dt_by_account.max_dt = joined_transactions.dt
GROUP BY max_dt_by_account.account
)
-- Get running balance at last transaction for each account
SELECT max_tid_by_account.account, running_balance AS quantity
FROM max_tid_by_account
JOIN transactions_with_running_balances ON max_tid = transactions_with_running_balances.transaction_id AND max_tid_by_account.account = transactions_with_running_balances.account"
).bind(format_date(date)).fetch_all(&mut connection).await.expect("SQL error");
let mut balances = HashMap::new();
for row in rows {
balances.insert(row.get("account"), row.get("quantity"));
}
balances
}
/// Get transactions from the database
pub async fn get_transactions(&self) -> Vec<TransactionWithPostings> {
let mut connection = self.connect().await;
let rows = sqlx::query(
"SELECT transaction_id, dt, transaction_description, id, description, account, quantity, commodity, quantity_ascost
FROM transactions_with_quantity_ascost
ORDER BY dt, transaction_id, id"
).fetch_all(&mut connection).await.expect("SQL error");
// Un-flatten transaction list
let mut transactions: Vec<TransactionWithPostings> = Vec::new();
for row in rows {
if transactions.is_empty()
|| transactions.last().unwrap().transaction.id != row.get("transaction_id")
{
// New transaction
transactions.push(TransactionWithPostings {
transaction: Transaction {
id: row.get("transaction_id"),
dt: NaiveDateTime::parse_from_str(row.get("dt"), "%Y-%m-%d %H:%M:%S.%6f")
.expect("Invalid transactions.dt"),
description: row.get("transaction_description"),
},
postings: Vec::new(),
});
}
transactions.last_mut().unwrap().postings.push(Posting {
id: row.get("id"),
transaction_id: row.get("transaction_id"),
description: row.get("description"),
account: row.get("account"),
quantity: row.get("quantity"),
commodity: row.get("commodity"),
quantity_ascost: row.get("quantity_ascost"),
});
}
transactions
}
/// Get unreconciled statement lines from the database
pub async fn get_unreconciled_statement_lines(&self) -> Vec<StatementLine> {
let mut connection = self.connect().await;
let rows = sqlx::query(
// 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"
).map(|r: SqliteRow| StatementLine {
id: Some(r.get("id")),
source_account: r.get("source_account"),
dt: NaiveDateTime::parse_from_str(r.get("dt"), "%Y-%m-%d").expect("Invalid statement_lines.dt"),
description: r.get("description"),
quantity: r.get("quantity"),
balance: r.get("balance"),
commodity: r.get("commodity"),
}).fetch_all(&mut connection).await.expect("SQL error");
rows
}
}
/// Container for cached database-related metadata
pub struct DbMetadata {
pub version: u32,
pub eofy_date: NaiveDate,
pub reporting_commodity: String,
pub dps: u32,
}
impl DbMetadata {
/// Initialise [DbMetadata] with values from the metadata database table
async fn from_database(connection: &mut SqliteConnection) -> Self {
let version = sqlx::query("SELECT value FROM metadata WHERE key = 'version'")
.map(|r: SqliteRow| {
r.get::<String, _>(0)
.parse()
.expect("Invalid metadata.version")
})
.fetch_one(&mut *connection)
.await
.expect("SQL error");
let eofy_date = sqlx::query("SELECT value FROM metadata WHERE key ='eofy_date'")
.map(|r: SqliteRow| {
NaiveDate::parse_from_str(r.get(0), "%Y-%m-%d").expect("Invalid metadata.eofy_date")
})
.fetch_one(&mut *connection)
.await
.expect("SQL error");
let reporting_commodity =
sqlx::query("SELECT value FROM metadata WHERE key = 'reporting_commodity'")
.map(|r: SqliteRow| r.get(0))
.fetch_one(&mut *connection)
.await
.expect("SQL error");
let dps = sqlx::query("SELECT value FROM metadata WHERE key = 'amount_dps'")
.map(|r: SqliteRow| {
r.get::<String, _>(0)
.parse()
.expect("Invalid metadata.amount_dps")
})
.fetch_one(&mut *connection)
.await
.expect("SQL error");
DbMetadata {
version,
eofy_date,
reporting_commodity,
dps,
}
}
}

14
libdrcr/src/lib.rs Normal file
View File

@ -0,0 +1,14 @@
pub mod account_config;
pub mod db;
pub mod model;
pub mod reporting;
pub mod serde;
pub mod util;
/// Data type used to represent transaction and account quantities
pub type QuantityInt = i64;
// Magic strings
// TODO: Make this configurable
pub const CURRENT_YEAR_EARNINGS: &'static str = "Current Year Earnings";
pub const RETAINED_EARNINGS: &'static str = "Retained Earnings";

237
libdrcr/src/main.rs Normal file
View File

@ -0,0 +1,237 @@
/*
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::sync::Arc;
use chrono::NaiveDate;
use libdrcr::db::DbConnection;
use libdrcr::reporting::builders::register_dynamic_builders;
use libdrcr::reporting::calculator::{steps_as_graphviz, steps_for_targets};
use libdrcr::reporting::dynamic_report::DynamicReport;
use libdrcr::reporting::generate_report;
use libdrcr::reporting::steps::register_lookup_fns;
use libdrcr::reporting::types::{
DateArgs, DateStartDateEndArgs, MultipleDateArgs, MultipleDateStartDateEndArgs,
ReportingContext, ReportingProductId, ReportingProductKind, VoidArgs,
};
#[tokio::main]
async fn main() {
const YEAR: i32 = 2025;
// Connect to database
let db_connection = DbConnection::new("sqlite:drcr_testing.db").await;
// Initialise ReportingContext
let mut context = ReportingContext::new(
db_connection,
NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
"$".to_string(),
);
register_lookup_fns(&mut context);
register_dynamic_builders(&mut context);
let context = Arc::new(context);
// Print Graphviz
let targets = vec![
ReportingProductId {
name: "CalculateIncomeTax",
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
},
// ReportingProductId {
// name: "AllTransactionsExceptEarningsToEquity",
// kind: ReportingProductKind::Transactions,
// args: Box::new(DateArgs {
// date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
// }),
// },
ReportingProductId {
name: "BalanceSheet",
kind: ReportingProductKind::Generic,
args: Box::new(MultipleDateArgs {
dates: vec![DateArgs {
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}],
}),
},
ReportingProductId {
name: "IncomeStatement",
kind: ReportingProductKind::Generic,
args: Box::new(MultipleDateStartDateEndArgs {
dates: vec![DateStartDateEndArgs {
date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(),
date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}],
}),
},
];
let (sorted_steps, dependencies) = steps_for_targets(targets, &context).unwrap();
println!("Graphviz:");
println!("{}", steps_as_graphviz(&sorted_steps, &dependencies));
// Get income statement
let targets = vec![
ReportingProductId {
name: "CalculateIncomeTax",
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
},
ReportingProductId {
name: "AllTransactionsExceptEarningsToEquity",
kind: ReportingProductKind::BalancesBetween,
args: Box::new(DateStartDateEndArgs {
date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(),
date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}),
},
];
let products = generate_report(targets, Arc::clone(&context))
.await
.unwrap();
let result = products
.get_or_err(&ReportingProductId {
name: "AllTransactionsExceptEarningsToEquity",
kind: ReportingProductKind::BalancesBetween,
args: Box::new(DateStartDateEndArgs {
date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(),
date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}),
})
.unwrap();
println!("Income statement:");
println!("{:?}", result);
// Get balance sheet
let targets = vec![
ReportingProductId {
name: "CalculateIncomeTax",
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
},
ReportingProductId {
name: "BalanceSheet",
kind: ReportingProductKind::Generic,
args: Box::new(MultipleDateArgs {
dates: vec![DateArgs {
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}],
}),
},
];
let products = generate_report(targets, Arc::clone(&context))
.await
.unwrap();
let result = products
.get_or_err(&ReportingProductId {
name: "BalanceSheet",
kind: ReportingProductKind::Generic,
args: Box::new(MultipleDateArgs {
dates: vec![DateArgs {
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}],
}),
})
.unwrap();
println!("Balance sheet:");
println!(
"{}",
result.downcast_ref::<DynamicReport>().unwrap().to_json()
);
// Get trial balance
let targets = vec![
ReportingProductId {
name: "CalculateIncomeTax",
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
},
ReportingProductId {
name: "TrialBalance",
kind: ReportingProductKind::Generic,
args: Box::new(DateArgs {
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}),
},
];
let products = generate_report(targets, Arc::clone(&context))
.await
.unwrap();
let result = products
.get_or_err(&ReportingProductId {
name: "TrialBalance",
kind: ReportingProductKind::Generic,
args: Box::new(DateArgs {
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}),
})
.unwrap();
println!("Trial balance:");
println!(
"{}",
result.downcast_ref::<DynamicReport>().unwrap().to_json()
);
// Get all transactions
/*let targets = vec![
ReportingProductId {
name: "CalculateIncomeTax",
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
},
ReportingProductId {
name: "AllTransactionsExceptEarningsToEquity",
kind: ReportingProductKind::Transactions,
args: Box::new(DateArgs {
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}),
},
];
let products = generate_report(targets, Arc::clone(&context))
.await
.unwrap();
let result = products
.get_or_err(&ReportingProductId {
name: "AllTransactionsExceptEarningsToEquity",
kind: ReportingProductKind::Transactions,
args: Box::new(DateArgs {
date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(),
}),
})
.unwrap();
println!("All transactions:");
println!(
"{}",
result.downcast_ref::<Transactions>().unwrap().to_json()
);*/
}

View File

@ -0,0 +1,33 @@
/*
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 chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use crate::QuantityInt;
#[derive(Deserialize, Serialize)]
pub struct BalanceAssertion {
pub id: Option<u64>,
#[serde(with = "crate::serde::naivedatetime_to_js")]
pub dt: NaiveDateTime,
pub description: String,
pub account: String,
pub quantity: QuantityInt,
pub commodity: String,
}

View File

@ -1,25 +1,21 @@
/*
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 Lee Yingtong Li (RunasSudo)
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/>.
*/
import { DrcrReport } from './base.ts';
export default class TrialBalanceReport implements DrcrReport {
constructor(
public balances: Map<string, number>
) {}
}
pub mod assertions;
pub mod statements;
pub mod transaction;

View File

@ -0,0 +1,31 @@
/*
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 chrono::NaiveDateTime;
use crate::QuantityInt;
pub struct StatementLine {
pub id: Option<u64>,
pub source_account: String,
pub dt: NaiveDateTime,
pub description: String,
pub quantity: QuantityInt,
pub balance: QuantityInt,
pub commodity: String,
}

View File

@ -0,0 +1,67 @@
/*
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 chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use crate::QuantityInt;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Transaction {
pub id: Option<u64>,
#[serde(with = "crate::serde::naivedatetime_to_js")]
pub dt: NaiveDateTime,
pub description: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TransactionWithPostings {
#[serde(flatten)]
pub transaction: Transaction,
pub postings: Vec<Posting>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Posting {
pub id: Option<u64>,
pub transaction_id: Option<u64>,
pub description: Option<String>,
pub account: String,
pub quantity: QuantityInt,
pub commodity: String,
pub quantity_ascost: Option<QuantityInt>,
//pub running_balance: Option<QuantityInt>,
}
pub(crate) fn update_balances_from_transactions<
'a,
I: Iterator<Item = &'a TransactionWithPostings>,
>(
balances: &mut HashMap<String, QuantityInt>,
transactions: I,
) {
for transaction in transactions {
for posting in transaction.postings.iter() {
// FIXME: Do currency conversion
let running_balance = balances.get(&posting.account).unwrap_or(&0) + posting.quantity;
balances.insert(posting.account.clone(), running_balance);
}
}
}

View File

@ -0,0 +1,855 @@
/*
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/>.
*/
//! This module contains implementations of dynamic step builders
//!
//! See [ReportingContext::register_dynamic_builder][super::types::ReportingContext::register_dynamic_builder].
use std::collections::HashMap;
use std::fmt::Display;
use async_trait::async_trait;
use tokio::sync::RwLock;
use crate::model::transaction::update_balances_from_transactions;
use super::calculator::{has_step_or_can_build, HasStepOrCanBuild, ReportingGraphDependencies};
use super::executor::ReportingExecutionError;
use super::types::{
BalancesAt, BalancesBetween, DateArgs, DateStartDateEndArgs, ReportingContext,
ReportingProductId, ReportingProductKind, ReportingProducts, ReportingStep, ReportingStepArgs,
ReportingStepDynamicBuilder, ReportingStepId, Transactions, VoidArgs,
};
/// Call [ReportingContext::register_dynamic_builder] for all dynamic builders provided by this module
pub fn register_dynamic_builders(context: &mut ReportingContext) {
GenerateBalances::register_dynamic_builder(context);
UpdateBalancesBetween::register_dynamic_builder(context);
UpdateBalancesAt::register_dynamic_builder(context);
// This is the least efficient way of generating BalancesBetween so put at the end
BalancesAtToBalancesBetween::register_dynamic_builder(context);
}
/// This dynamic builder automatically generates a [BalancesBetween] by subtracting [BalancesAt] between two dates
#[derive(Debug)]
pub struct BalancesAtToBalancesBetween {
step_name: &'static str,
args: DateStartDateEndArgs,
}
impl BalancesAtToBalancesBetween {
// Implements BalancesAt, BalancesAt -> BalancesBetween
fn register_dynamic_builder(context: &mut ReportingContext) {
context.register_dynamic_builder(ReportingStepDynamicBuilder {
name: "BalancesAtToBalancesBetween",
can_build: Self::can_build,
build: Self::build,
});
}
fn can_build(
name: &'static str,
kind: ReportingProductKind,
args: &Box<dyn ReportingStepArgs>,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
context: &ReportingContext,
) -> bool {
// Check for BalancesAt, BalancesAt -> BalancesBetween
if kind == ReportingProductKind::BalancesBetween {
if !args.is::<DateStartDateEndArgs>() {
return false;
}
let args = args.downcast_ref::<DateStartDateEndArgs>().unwrap();
match has_step_or_can_build(
&ReportingProductId {
name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs {
date: args.date_start.clone(),
}),
},
steps,
dependencies,
context,
) {
HasStepOrCanBuild::HasStep(_)
| HasStepOrCanBuild::CanLookup(_)
| HasStepOrCanBuild::CanBuild(_) => {
return true;
}
HasStepOrCanBuild::None => {}
}
}
return false;
}
fn build(
name: &'static str,
_kind: ReportingProductKind,
args: Box<dyn ReportingStepArgs>,
_steps: &Vec<Box<dyn ReportingStep>>,
_dependencies: &ReportingGraphDependencies,
_context: &ReportingContext,
) -> Box<dyn ReportingStep> {
Box::new(BalancesAtToBalancesBetween {
step_name: name,
args: *args.downcast().unwrap(),
})
}
}
impl Display for BalancesAtToBalancesBetween {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"{} {{BalancesAtToBalancesBetween}}",
self.id()
))
}
}
#[async_trait]
impl ReportingStep for BalancesAtToBalancesBetween {
fn id(&self) -> ReportingStepId {
ReportingStepId {
name: self.step_name,
product_kinds: &[ReportingProductKind::BalancesBetween],
args: Box::new(self.args.clone()),
}
}
fn requires(&self, _context: &ReportingContext) -> Vec<ReportingProductId> {
// BalancesAtToBalancesBetween depends on BalancesAt at both time points
vec![
ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs {
date: self.args.date_start.pred_opt().unwrap(), // Opening balance is the closing balance of the preceding day
}),
},
ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs {
date: self.args.date_end,
}),
},
]
}
async fn execute(
&self,
_context: &ReportingContext,
_steps: &Vec<Box<dyn ReportingStep>>,
_dependencies: &ReportingGraphDependencies,
products: &RwLock<ReportingProducts>,
) -> Result<ReportingProducts, ReportingExecutionError> {
let products = products.read().await;
// Get balances at dates
let balances_start = &products
.get_or_err(&ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs {
date: self.args.date_start.pred_opt().unwrap(), // Opening balance is the closing balance of the preceding day
}),
})?
.downcast_ref::<BalancesAt>()
.unwrap()
.balances;
let balances_end = &products
.get_or_err(&ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs {
date: self.args.date_end,
}),
})?
.downcast_ref::<BalancesAt>()
.unwrap()
.balances;
// Compute balances_end - balances_start
let mut balances = BalancesBetween {
balances: balances_end.clone(),
};
for (account, balance) in balances_start.iter() {
let running_balance = balances.balances.get(account).unwrap_or(&0) - balance;
balances.balances.insert(account.clone(), running_balance);
}
// Store result
let mut result = ReportingProducts::new();
result.insert(
ReportingProductId {
name: self.id().name,
kind: ReportingProductKind::BalancesBetween,
args: Box::new(self.args.clone()),
},
Box::new(balances),
);
Ok(result)
}
}
/// This dynamic builder automatically generates a [BalancesAt] from a step which has no dependencies and generates [Transactions] (e.g. [PostUnreconciledStatementLines][super::steps::PostUnreconciledStatementLines])
#[derive(Debug)]
pub struct GenerateBalances {
step_name: &'static str,
args: DateArgs,
}
impl GenerateBalances {
fn register_dynamic_builder(context: &mut ReportingContext) {
context.register_dynamic_builder(ReportingStepDynamicBuilder {
name: "GenerateBalances",
can_build: Self::can_build,
build: Self::build,
});
}
fn can_build(
name: &'static str,
kind: ReportingProductKind,
args: &Box<dyn ReportingStepArgs>,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
context: &ReportingContext,
) -> bool {
// Check for Transactions -> BalancesAt
if kind == ReportingProductKind::BalancesAt {
// Try DateArgs
match has_step_or_can_build(
&ReportingProductId {
name,
kind: ReportingProductKind::Transactions,
args: args.clone(),
},
steps,
dependencies,
context,
) {
HasStepOrCanBuild::HasStep(step) => {
// Check for () -> Transactions
if dependencies.dependencies_for_step(&step.id()).len() == 0 {
return true;
}
}
HasStepOrCanBuild::CanLookup(lookup_fn) => {
// Check for () -> Transactions
let step = lookup_fn(args.clone());
if step.requires(context).len() == 0 {
return true;
}
}
HasStepOrCanBuild::CanBuild(_) | HasStepOrCanBuild::None => {}
}
// Try VoidArgs
match has_step_or_can_build(
&ReportingProductId {
name,
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
},
steps,
dependencies,
context,
) {
HasStepOrCanBuild::HasStep(step) => {
// Check for () -> Transactions
if dependencies.dependencies_for_step(&step.id()).len() == 0 {
return true;
}
}
HasStepOrCanBuild::CanLookup(lookup_fn) => {
// Check for () -> Transactions
let step = lookup_fn(args.clone());
if step.requires(context).len() == 0 {
return true;
}
}
HasStepOrCanBuild::CanBuild(_) | HasStepOrCanBuild::None => {}
}
}
return false;
}
fn build(
name: &'static str,
_kind: ReportingProductKind,
args: Box<dyn ReportingStepArgs>,
_steps: &Vec<Box<dyn ReportingStep>>,
_dependencies: &ReportingGraphDependencies,
_context: &ReportingContext,
) -> Box<dyn ReportingStep> {
Box::new(GenerateBalances {
step_name: name,
args: *args.downcast().unwrap(),
})
}
}
impl Display for GenerateBalances {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{} {{GenerateBalances}}", self.id()))
}
}
#[async_trait]
impl ReportingStep for GenerateBalances {
fn id(&self) -> ReportingStepId {
ReportingStepId {
name: self.step_name,
product_kinds: &[ReportingProductKind::BalancesAt],
args: Box::new(self.args.clone()),
}
}
fn init_graph(
&self,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &mut ReportingGraphDependencies,
context: &ReportingContext,
) {
// Add a dependency on the Transactions result
// Look up that step, so we can extract the appropriate args
// Try DateArgs
match has_step_or_can_build(
&ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::Transactions,
args: Box::new(self.args.clone()),
},
steps,
dependencies,
context,
) {
HasStepOrCanBuild::HasStep(_)
| HasStepOrCanBuild::CanLookup(_)
| HasStepOrCanBuild::CanBuild(_) => {
dependencies.add_dependency(
self.id(),
ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::Transactions,
args: Box::new(self.args.clone()),
},
);
return;
}
HasStepOrCanBuild::None => (),
}
// Must be VoidArgs (as checked in can_build)
dependencies.add_dependency(
self.id(),
ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
},
);
}
async fn execute(
&self,
_context: &ReportingContext,
_steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
products: &RwLock<ReportingProducts>,
) -> Result<ReportingProducts, ReportingExecutionError> {
let products = products.read().await;
// Get the transactions
let transactions_product = &dependencies.dependencies_for_step(&self.id())[0].product;
let transactions = &products
.get_or_err(transactions_product)?
.downcast_ref::<Transactions>()
.unwrap()
.transactions;
// Sum balances
let mut balances = BalancesAt {
balances: HashMap::new(),
};
update_balances_from_transactions(&mut balances.balances, transactions.iter());
// Store result
let mut result = ReportingProducts::new();
result.insert(
ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(self.args.clone()),
},
Box::new(balances),
);
Ok(result)
}
}
/// This dynamic builder automatically generates a [BalancesAt] from:
/// - a step which generates [Transactions] from [BalancesAt], or
/// - a step which generates [Transactions] from [BalancesBetween], and for which a [BalancesAt] is also available
#[derive(Debug)]
pub struct UpdateBalancesAt {
step_name: &'static str,
args: DateArgs,
}
impl UpdateBalancesAt {
// Implements (BalancesAt -> Transactions) -> BalancesAt
fn register_dynamic_builder(context: &mut ReportingContext) {
context.register_dynamic_builder(ReportingStepDynamicBuilder {
name: "UpdateBalancesAt",
can_build: Self::can_build,
build: Self::build,
});
}
fn can_build(
name: &'static str,
kind: ReportingProductKind,
args: &Box<dyn ReportingStepArgs>,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
context: &ReportingContext,
) -> bool {
if !args.is::<DateArgs>() {
return false;
}
// Check for Transactions -> BalancesAt
if kind == ReportingProductKind::BalancesAt {
// Initially no need to check args
if let Some(step) = steps.iter().find(|s| {
s.id().name == name
&& s.id()
.product_kinds
.contains(&ReportingProductKind::Transactions)
}) {
// Check for BalancesAt -> Transactions
let dependencies_for_step = dependencies.dependencies_for_step(&step.id());
if dependencies_for_step.len() == 1
&& dependencies_for_step[0].product.kind == ReportingProductKind::BalancesAt
{
return true;
}
// Check if BalancesBetween -> Transactions and BalancesAt is available
if dependencies_for_step.len() == 1
&& dependencies_for_step[0].product.kind
== ReportingProductKind::BalancesBetween
{
match has_step_or_can_build(
&ReportingProductId {
name: dependencies_for_step[0].product.name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs {
date: args.downcast_ref::<DateArgs>().unwrap().date,
}),
},
steps,
dependencies,
context,
) {
HasStepOrCanBuild::HasStep(_)
| HasStepOrCanBuild::CanLookup(_)
| HasStepOrCanBuild::CanBuild(_) => {
return true;
}
HasStepOrCanBuild::None => {}
}
}
}
}
return false;
}
fn build(
name: &'static str,
_kind: ReportingProductKind,
args: Box<dyn ReportingStepArgs>,
_steps: &Vec<Box<dyn ReportingStep>>,
_dependencies: &ReportingGraphDependencies,
_context: &ReportingContext,
) -> Box<dyn ReportingStep> {
Box::new(UpdateBalancesAt {
step_name: name,
args: *args.downcast().unwrap(),
})
}
}
impl Display for UpdateBalancesAt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{} {{UpdateBalancesAt}}", self.id()))
}
}
#[async_trait]
impl ReportingStep for UpdateBalancesAt {
fn id(&self) -> ReportingStepId {
ReportingStepId {
name: self.step_name,
product_kinds: &[ReportingProductKind::BalancesAt],
args: Box::new(self.args.clone()),
}
}
fn init_graph(
&self,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &mut ReportingGraphDependencies,
_context: &ReportingContext,
) {
// Add a dependency on the Transactions result
// Look up that step, so we can extract the appropriate args
let parent_step = steps
.iter()
.find(|s| {
s.id().name == self.step_name
&& s.id()
.product_kinds
.contains(&ReportingProductKind::Transactions)
})
.unwrap(); // Existence is checked in can_build
dependencies.add_dependency(
self.id(),
ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::Transactions,
args: parent_step.id().args.clone(),
},
);
// Look up the BalancesAt step
let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id());
let dependency = &dependencies_for_step[0].product; // Existence and uniqueness checked in can_build
if dependency.kind == ReportingProductKind::BalancesAt {
// Directly depends on BalancesAt -> Transaction
// Do not need to add extra dependencies
} else {
// As checked in can_build, must depend on BalancesBetween -> Transaction with a BalancesAt available
dependencies.add_dependency(
self.id(),
ReportingProductId {
name: dependency.name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs {
date: self.args.date,
}),
},
);
}
}
async fn execute(
&self,
_context: &ReportingContext,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
products: &RwLock<ReportingProducts>,
) -> Result<ReportingProducts, ReportingExecutionError> {
let products = products.read().await;
// Look up the parent step, so we can extract the appropriate args
let parent_step = steps
.iter()
.find(|s| {
s.id().name == self.step_name
&& s.id()
.product_kinds
.contains(&ReportingProductKind::Transactions)
})
.unwrap(); // Existence is checked in can_build
// Get transactions
let transactions = &products
.get_or_err(&ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::Transactions,
args: parent_step.id().args,
})?
.downcast_ref::<Transactions>()
.unwrap()
.transactions;
// Look up the BalancesAt step
let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id());
let dependency = &dependencies_for_step[0].product; // Existence and uniqueness checked in can_build
let opening_balances_at;
if dependency.kind == ReportingProductKind::BalancesAt {
// Directly depends on BalancesAt -> Transaction
opening_balances_at = products
.get_or_err(&dependency)?
.downcast_ref::<BalancesAt>()
.unwrap();
} else {
// As checked in can_build, must depend on BalancesBetween -> Transaction with a BalancesAt available
opening_balances_at = products
.get_or_err(&ReportingProductId {
name: dependency.name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs {
date: self.args.date,
}),
})?
.downcast_ref()
.unwrap();
}
// Sum balances
let mut balances = BalancesAt {
balances: opening_balances_at.balances.clone(),
};
update_balances_from_transactions(
&mut balances.balances,
transactions
.iter()
.filter(|t| t.transaction.dt.date() <= self.args.date),
);
// Store result
let mut result = ReportingProducts::new();
result.insert(
ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::BalancesAt,
args: Box::new(self.args.clone()),
},
Box::new(balances),
);
Ok(result)
}
}
/// This dynamic builder automatically generates a [BalancesBetween] from a step which generates [Transactions] from [BalancesBetween]
#[derive(Debug)]
pub struct UpdateBalancesBetween {
step_name: &'static str,
args: DateStartDateEndArgs,
}
impl UpdateBalancesBetween {
fn register_dynamic_builder(context: &mut ReportingContext) {
context.register_dynamic_builder(ReportingStepDynamicBuilder {
name: "UpdateBalancesBetween",
can_build: Self::can_build,
build: Self::build,
});
}
fn can_build(
name: &'static str,
kind: ReportingProductKind,
_args: &Box<dyn ReportingStepArgs>,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
_context: &ReportingContext,
) -> bool {
// Check for Transactions -> BalancesBetween
if kind == ReportingProductKind::BalancesBetween {
// Initially no need to check args
if let Some(step) = steps.iter().find(|s| {
s.id().name == name
&& s.id()
.product_kinds
.contains(&ReportingProductKind::Transactions)
}) {
// Check for BalancesBetween -> Transactions
let dependencies_for_step = dependencies.dependencies_for_step(&step.id());
if dependencies_for_step.len() == 1
&& dependencies_for_step[0].product.kind
== ReportingProductKind::BalancesBetween
{
return true;
}
}
}
return false;
}
fn build(
name: &'static str,
_kind: ReportingProductKind,
args: Box<dyn ReportingStepArgs>,
_steps: &Vec<Box<dyn ReportingStep>>,
_dependencies: &ReportingGraphDependencies,
_context: &ReportingContext,
) -> Box<dyn ReportingStep> {
Box::new(UpdateBalancesBetween {
step_name: name,
args: *args.downcast().unwrap(),
})
}
}
impl Display for UpdateBalancesBetween {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{} {{UpdateBalancesBetween}}", self.id()))
}
}
#[async_trait]
impl ReportingStep for UpdateBalancesBetween {
fn id(&self) -> ReportingStepId {
ReportingStepId {
name: self.step_name,
product_kinds: &[ReportingProductKind::BalancesBetween],
args: Box::new(self.args.clone()),
}
}
fn init_graph(
&self,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &mut ReportingGraphDependencies,
_context: &ReportingContext,
) {
// Add a dependency on the Transactions result
// Look up that step, so we can extract the appropriate args
let parent_step = steps
.iter()
.find(|s| {
s.id().name == self.step_name
&& s.id()
.product_kinds
.contains(&ReportingProductKind::Transactions)
})
.unwrap(); // Existence is checked in can_build
dependencies.add_dependency(
self.id(),
ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::Transactions,
args: parent_step.id().args,
},
);
// Look up the BalancesBetween step
let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id());
let balances_between_product = &dependencies_for_step[0].product; // Existence and uniqueness checked in can_build
if *balances_between_product
.args
.downcast_ref::<DateStartDateEndArgs>()
.unwrap() == self.args
{
// Directly depends on BalanceBetween -> Transaction with appropriate date
// Do not need to add extra dependencies
} else {
// Depends on BalanceBetween with appropriate date
dependencies.add_dependency(
self.id(),
ReportingProductId {
name: balances_between_product.name,
kind: ReportingProductKind::BalancesBetween,
args: Box::new(self.args.clone()),
},
);
}
}
async fn execute(
&self,
_context: &ReportingContext,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
products: &RwLock<ReportingProducts>,
) -> Result<ReportingProducts, ReportingExecutionError> {
let products = products.read().await;
// Look up the parent step, so we can extract the appropriate args
let parent_step = steps
.iter()
.find(|s| {
s.id().name == self.step_name
&& s.id()
.product_kinds
.contains(&ReportingProductKind::Transactions)
})
.unwrap(); // Existence is checked in can_build
// Get transactions
let transactions = &products
.get_or_err(&ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::Transactions,
args: parent_step.id().args,
})?
.downcast_ref::<Transactions>()
.unwrap()
.transactions;
// Look up the BalancesBetween step
let dependencies_for_step = dependencies.dependencies_for_step(&parent_step.id());
let balances_between_product = &dependencies_for_step[0].product; // Existence and uniqueness is checked in can_build
// Get opening balances
let opening_balances = &products
.get_or_err(&ReportingProductId {
name: balances_between_product.name,
kind: ReportingProductKind::BalancesBetween,
args: Box::new(self.args.clone()),
})?
.downcast_ref::<BalancesBetween>()
.unwrap()
.balances;
// Sum balances
let mut balances = BalancesBetween {
balances: opening_balances.clone(),
};
update_balances_from_transactions(
&mut balances.balances,
transactions.iter().filter(|t| {
t.transaction.dt.date() >= self.args.date_start
&& t.transaction.dt.date() <= self.args.date_end
}),
);
// Store result
let mut result = ReportingProducts::new();
result.insert(
ReportingProductId {
name: self.step_name,
kind: ReportingProductKind::BalancesBetween,
args: Box::new(self.args.clone()),
},
Box::new(balances),
);
Ok(result)
}
}

View File

@ -0,0 +1,429 @@
/*
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/>.
*/
//! This module implements the dependency resolution for [ReportingStep]s
use super::types::{
ReportingContext, ReportingProductId, ReportingStep, ReportingStepDynamicBuilder,
ReportingStepFromArgsFn, ReportingStepId,
};
/// List of dependencies between [ReportingStep]s and [ReportingProduct][super::types::ReportingProduct]s
#[derive(Debug)]
pub struct ReportingGraphDependencies {
vec: Vec<Dependency>,
}
impl ReportingGraphDependencies {
/// Get the list of [Dependency]s
pub fn vec(&self) -> &Vec<Dependency> {
&self.vec
}
/// Record that the [ReportingStep] depends on the [ReportingProduct][super::types::ReportingProduct]
pub fn add_dependency(&mut self, step: ReportingStepId, product: ReportingProductId) {
if !self
.vec
.iter()
.any(|d| d.step == step && d.product == product)
{
self.vec.push(Dependency { step, product });
}
}
/// Get the [Dependency]s for the given [ReportingStep]
pub fn dependencies_for_step(&self, step: &ReportingStepId) -> Vec<&Dependency> {
return self.vec.iter().filter(|d| d.step == *step).collect();
}
}
/// Represents that a [ReportingStep] depends on a [ReportingProduct][super::types::ReportingProduct]
#[derive(Debug)]
pub struct Dependency {
pub step: ReportingStepId,
pub product: ReportingProductId,
}
/// Indicates an error during dependency resolution in [steps_for_targets]
#[derive(Debug)]
pub enum ReportingCalculationError {
UnknownStep { message: String },
NoStepForProduct { message: String },
CircularDependencies,
}
pub enum HasStepOrCanBuild<'a, 'b> {
HasStep(&'a Box<dyn ReportingStep>),
CanLookup(ReportingStepFromArgsFn),
CanBuild(&'b ReportingStepDynamicBuilder),
None,
}
/// Determines whether the [ReportingProduct][super::types::ReportingProduct] is generated by a known step, or can be generated by a lookup function or dynamic builder
pub fn has_step_or_can_build<'a, 'b>(
product: &ReportingProductId,
steps: &'a Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
context: &'b ReportingContext,
) -> HasStepOrCanBuild<'a, 'b> {
if let Some(step) = steps.iter().find(|s| {
s.id().name == product.name
&& s.id().args == product.args
&& s.id().product_kinds.contains(&product.kind)
}) {
return HasStepOrCanBuild::HasStep(step);
}
// Try lookup function
if let Some(lookup_key) = context
.step_lookup_fn
.keys()
.find(|(name, kinds)| *name == product.name && kinds.contains(&product.kind))
{
let (takes_args_fn, from_args_fn) = context.step_lookup_fn.get(lookup_key).unwrap();
if takes_args_fn(&product.args) {
return HasStepOrCanBuild::CanLookup(*from_args_fn);
}
}
// No explicit step for product - try builders
for builder in context.step_dynamic_builders.iter() {
if (builder.can_build)(
product.name,
product.kind,
&product.args,
steps,
dependencies,
context,
) {
return HasStepOrCanBuild::CanBuild(builder);
}
}
return HasStepOrCanBuild::None;
}
/// Generates a new step which generates the requested [ReportingProduct][super::types::ReportingProduct], using a lookup function or dynamic builder
///
/// Panics if a known step already generates the requested [ReportingProduct][super::types::ReportingProduct].
fn build_step_for_product(
product: &ReportingProductId,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
context: &ReportingContext,
) -> Option<Box<dyn ReportingStep>> {
let new_step;
match has_step_or_can_build(product, steps, dependencies, context) {
HasStepOrCanBuild::HasStep(_) => {
panic!("Attempted to call build_step_for_product for already existing step")
}
HasStepOrCanBuild::CanLookup(from_args_fn) => {
new_step = from_args_fn(product.args.clone());
// Check new step meets the dependency
if new_step.id().name != product.name {
panic!(
"Unexpected step returned from lookup function (expected name {}, got {})",
product.name,
new_step.id().name
);
}
if new_step.id().args != product.args {
panic!(
"Unexpected step returned from lookup function {} (expected args {:?}, got {:?})",
product.name,
product.args,
new_step.id().args
);
}
if !new_step.id().product_kinds.contains(&product.kind) {
panic!(
"Unexpected step returned from lookup function {} (expected kind {:?}, got {:?})",
product.name,
product.kind,
new_step.id().product_kinds
);
}
}
HasStepOrCanBuild::CanBuild(builder) => {
new_step = (builder.build)(
product.name,
product.kind,
product.args.clone(),
&steps,
&dependencies,
&context,
);
// Check new step meets the dependency
if new_step.id().name != product.name {
panic!(
"Unexpected step returned from builder {} (expected name {}, got {})",
builder.name,
product.name,
new_step.id().name
);
}
if new_step.id().args != product.args {
panic!(
"Unexpected step returned from builder {} for {} (expected args {:?}, got {:?})",
builder.name,
product.name,
product.args,
new_step.id().args
);
}
if !new_step.id().product_kinds.contains(&product.kind) {
panic!(
"Unexpected step returned from builder {} for {} (expected kind {:?}, got {:?})",
builder.name,
product.name,
product.kind,
new_step.id().product_kinds
);
}
}
HasStepOrCanBuild::None => {
return None;
}
}
Some(new_step)
}
/// Check whether the [ReportingStep] would be ready to execute, if the given previous steps have already completed
pub(crate) fn would_be_ready_to_execute(
step: &Box<dyn ReportingStep>,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
previous_steps: &Vec<usize>,
) -> bool {
'check_each_dependency: for dependency in dependencies.vec.iter() {
if dependency.step == step.id() {
// Check if the dependency has been produced by a previous step
for previous_step in previous_steps {
if steps[*previous_step].id().name == dependency.product.name
&& steps[*previous_step].id().args == dependency.product.args
&& steps[*previous_step]
.id()
.product_kinds
.contains(&dependency.product.kind)
{
continue 'check_each_dependency;
}
}
// Dependency is not met
return false;
}
}
true
}
/// Recursively resolve the dependencies of the target [ReportingProductId]s and return a sorted [Vec] of [ReportingStep]s
pub fn steps_for_targets(
targets: Vec<ReportingProductId>,
context: &ReportingContext,
) -> Result<(Vec<Box<dyn ReportingStep>>, ReportingGraphDependencies), ReportingCalculationError> {
let mut steps: Vec<Box<dyn ReportingStep>> = Vec::new();
let mut dependencies = ReportingGraphDependencies { vec: Vec::new() };
// Process initial targets
for target in targets.iter() {
if !steps.iter().any(|s| {
s.id().name == target.name
&& s.id().args == target.args
&& s.id().product_kinds.contains(&target.kind)
}) {
// No current step generates the product - try to lookup or build
if let Some(new_step) = build_step_for_product(&target, &steps, &dependencies, context)
{
steps.push(new_step);
let new_step = steps.last().unwrap();
for dependency in new_step.requires(&context) {
dependencies.add_dependency(new_step.id(), dependency);
}
new_step.init_graph(&steps, &mut dependencies, &context);
} else {
return Err(ReportingCalculationError::NoStepForProduct {
message: format!("No step builds target product {}", target),
});
}
}
}
// Call after_init_graph
for step in steps.iter() {
step.as_ref()
.after_init_graph(&steps, &mut dependencies, &context);
}
// Recursively process dependencies
loop {
let mut new_steps = Vec::new();
for dependency in dependencies.vec.iter() {
if !steps.iter().any(|s| s.id() == dependency.step) {
// Unknown step for which a dependency has been declared
// FIXME: Call the lookup function
todo!();
}
if !steps.iter().any(|s| {
s.id().name == dependency.product.name
&& s.id().args == dependency.product.args
&& s.id().product_kinds.contains(&dependency.product.kind)
}) {
// No current step generates the product - try to lookup or build
if let Some(new_step) =
build_step_for_product(&dependency.product, &steps, &dependencies, context)
{
new_steps.push(new_step);
}
}
}
if new_steps.len() == 0 {
break;
}
// Initialise new steps
let mut new_step_indexes = Vec::new();
for new_step in new_steps {
new_step_indexes.push(steps.len());
steps.push(new_step);
let new_step = steps.last().unwrap();
for dependency in new_step.requires(&context) {
dependencies.add_dependency(new_step.id(), dependency);
}
new_step
.as_ref()
.init_graph(&steps, &mut dependencies, &context);
}
// Call after_init_graph on all steps
for step in steps.iter() {
step.as_ref()
.after_init_graph(&steps, &mut dependencies, &context);
}
}
// Check all dependencies satisfied
for dependency in dependencies.vec.iter() {
if !steps.iter().any(|s| s.id() == dependency.step) {
return Err(ReportingCalculationError::UnknownStep {
message: format!(
"No implementation for step {} which {} is a dependency of",
dependency.step, dependency.product
),
});
}
if !steps.iter().any(|s| {
s.id().name == dependency.product.name
&& s.id().args == dependency.product.args
&& s.id().product_kinds.contains(&dependency.product.kind)
}) {
return Err(ReportingCalculationError::NoStepForProduct {
message: format!(
"No step builds product {} wanted by {}",
dependency.product, dependency.step
),
});
}
}
// Sort
let mut sorted_step_indexes = Vec::new();
let mut steps_remaining = steps.iter().enumerate().collect::<Vec<_>>();
'loop_until_all_sorted: while !steps_remaining.is_empty() {
for (cur_index, (orig_index, step)) in steps_remaining.iter().enumerate() {
if would_be_ready_to_execute(step, &steps, &dependencies, &sorted_step_indexes) {
sorted_step_indexes.push(*orig_index);
steps_remaining.remove(cur_index);
continue 'loop_until_all_sorted;
}
}
// No steps to execute - must be circular dependency
return Err(ReportingCalculationError::CircularDependencies);
}
let mut sort_mapping = vec![0_usize; sorted_step_indexes.len()];
for i in 0..sorted_step_indexes.len() {
sort_mapping[sorted_step_indexes[i]] = i;
}
// TODO: This can be done in place
let mut sorted_steps = steps.into_iter().zip(sort_mapping).collect::<Vec<_>>();
sorted_steps.sort_unstable_by_key(|(_s, order)| *order);
let sorted_steps = sorted_steps
.into_iter()
.map(|(s, _idx)| s)
.collect::<Vec<_>>();
Ok((sorted_steps, dependencies))
}
/// Generate graphviz code representing the dependency tree
///
/// Useful for debugging or visualisation. Can be compiled using e.g. `dot -Tpdf -O output.gv`.
pub fn steps_as_graphviz(
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
) -> String {
let mut result = String::from("strict digraph drcr {\n");
// Output all steps
for step in steps.iter() {
let step_display_name = step.to_string();
if step_display_name.contains("{") {
// Bodge: Detect dynamic step builders
result.push_str(&format!(
"\"{}\" [shape=box, style=dashed, label=\"{}\"];\n",
step.id(),
step_display_name
));
} else {
result.push_str(&format!("\"{}\" [shape=box];\n", step.id()));
}
// Output the products of the step
for product_kind in step.id().product_kinds.iter() {
result.push_str(&format!(
"\"{}\" -> \"{}\";\n",
step.id(),
ReportingProductId {
name: step.id().name,
kind: *product_kind,
args: step.id().args
}
));
}
}
// Output all dependencies
for dependency in dependencies.vec().iter() {
result.push_str(&format!(
"\"{}\" -> \"{}\";\n",
dependency.product, dependency.step
));
}
result.push_str("}");
result
}

View File

@ -0,0 +1,562 @@
/*
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/>.
*/
// FIXME: Tidy up this file
use std::cell::RefCell;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::QuantityInt;
use super::types::ReportingProduct;
/// Represents a dynamically generated report composed of [CalculatableDynamicReportEntry]
#[derive(Clone, Debug)]
pub struct CalculatableDynamicReport {
pub title: String,
pub columns: Vec<String>,
// This must use RefCell as, during calculation, we iterate while mutating the report
pub entries: Vec<RefCell<CalculatableDynamicReportEntry>>,
}
impl CalculatableDynamicReport {
pub fn new(
title: String,
columns: Vec<String>,
entries: Vec<CalculatableDynamicReportEntry>,
) -> Self {
Self {
title,
columns,
entries: entries.into_iter().map(|e| RefCell::new(e)).collect(),
}
}
/// Recursively calculate all [CalculatedRow] entries
pub fn calculate(self) -> DynamicReport {
let mut calculated_entries = Vec::new();
for (entry_idx, entry) in self.entries.iter().enumerate() {
let entry_ref = entry.borrow();
match &*entry_ref {
CalculatableDynamicReportEntry::CalculatableSection(section) => {
// Clone first, in case calculation needs to take reference to the section
let updated_section = section.clone().calculate(&self);
drop(entry_ref); // Drop entry_ref so we can borrow mutably
let mut entry_mut = self.entries[entry_idx].borrow_mut();
*entry_mut = CalculatableDynamicReportEntry::Section(updated_section.clone());
calculated_entries.push(DynamicReportEntry::Section(updated_section));
}
CalculatableDynamicReportEntry::Section(section) => {
calculated_entries.push(DynamicReportEntry::Section(section.clone()));
}
CalculatableDynamicReportEntry::LiteralRow(row) => {
calculated_entries.push(DynamicReportEntry::LiteralRow(row.clone()));
}
CalculatableDynamicReportEntry::CalculatedRow(row) => {
let updated_row = row.calculate(&self);
drop(entry_ref); // Drop entry_ref so we can borrow mutably
let mut entry_mut = self.entries[entry_idx].borrow_mut();
*entry_mut = CalculatableDynamicReportEntry::LiteralRow(updated_row.clone());
calculated_entries.push(DynamicReportEntry::LiteralRow(updated_row));
}
CalculatableDynamicReportEntry::Spacer => {
calculated_entries.push(DynamicReportEntry::Spacer);
}
}
}
DynamicReport {
title: self.title,
columns: self.columns,
entries: calculated_entries,
}
}
/// Look up [CalculatableDynamicReportEntry] by id
///
/// Returns a cloned copy of the [CalculatableDynamicReportEntry]. This is necessary because the entry may be within a [Section], and [RefCell] semantics cannot express this type of nested borrow.
pub fn by_id(&self, id: &str) -> Option<CalculatableDynamicReportEntry> {
// Manually iterate over self.entries rather than self.entries()
// To catch the situation where entry is already mutably borrowed
for entry in self.entries.iter() {
match entry.try_borrow() {
Ok(entry) => match &*entry {
CalculatableDynamicReportEntry::CalculatableSection(section) => {
if let Some(i) = &section.id {
if i == id {
return Some(entry.clone());
}
}
if let Some(e) = section.by_id(id) {
return Some(e);
}
}
CalculatableDynamicReportEntry::Section(section) => {
if let Some(i) = &section.id {
if i == id {
return Some(entry.clone());
}
}
if let Some(e) = section.by_id(id) {
return Some(match e {
DynamicReportEntry::Section(section) => {
CalculatableDynamicReportEntry::Section(section.clone())
}
DynamicReportEntry::LiteralRow(row) => {
CalculatableDynamicReportEntry::LiteralRow(row.clone())
}
DynamicReportEntry::Spacer => {
CalculatableDynamicReportEntry::Spacer
}
});
}
}
CalculatableDynamicReportEntry::LiteralRow(row) => {
if let Some(i) = &row.id {
if i == id {
return Some(entry.clone());
}
}
}
CalculatableDynamicReportEntry::CalculatedRow(_) => (),
CalculatableDynamicReportEntry::Spacer => (),
},
Err(err) => panic!(
"Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}",
err
),
}
}
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 CalculatableDynamicReportEntry::CalculatableSection(section) = entry {
section.subtotal(&self)
} else {
panic!("Called subtotal_for_id on non-Section");
}
}
// Return the quantities for the [LiteralRow] with the given id
pub fn quantity_for_id(&self, id: &str) -> Vec<QuantityInt> {
let entry = self.by_id(id).expect("Invalid id");
if let CalculatableDynamicReportEntry::LiteralRow(row) = entry {
row.quantity
} else {
panic!("Called quantity_for_id on non-LiteralRow");
}
}
}
/// Represents a dynamically generated report composed of [DynamicReportEntry], with no [CalculatedRow]s
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DynamicReport {
pub title: String,
pub columns: Vec<String>,
pub entries: Vec<DynamicReportEntry>,
}
impl DynamicReport {
pub fn new(title: String, columns: Vec<String>, entries: Vec<DynamicReportEntry>) -> Self {
Self {
title,
columns,
entries,
}
}
/// 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::Spacer => true,
});
}
/// Serialise the report (as JSON) using serde
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap()
}
}
impl ReportingProduct for DynamicReport {}
#[derive(Clone, Debug)]
pub enum CalculatableDynamicReportEntry {
CalculatableSection(CalculatableSection),
Section(Section),
LiteralRow(LiteralRow),
CalculatedRow(CalculatedRow),
Spacer,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum DynamicReportEntry {
Section(Section),
LiteralRow(LiteralRow),
Spacer,
}
#[derive(Clone, Debug)]
pub struct CalculatableSection {
pub text: String,
pub id: Option<String>,
pub visible: bool,
pub auto_hide: bool,
pub entries: Vec<RefCell<CalculatableDynamicReportEntry>>,
}
impl CalculatableSection {
pub fn new(
text: String,
id: Option<String>,
visible: bool,
auto_hide: bool,
entries: Vec<CalculatableDynamicReportEntry>,
) -> Self {
Self {
text,
id,
visible,
auto_hide,
entries: entries.into_iter().map(|e| RefCell::new(e)).collect(),
}
}
/// Recursively calculate all [CalculatedRow] entries
pub fn calculate(&mut self, report: &CalculatableDynamicReport) -> Section {
let mut calculated_entries = Vec::new();
for (entry_idx, entry) in self.entries.iter().enumerate() {
let entry_ref = entry.borrow();
match &*entry_ref {
CalculatableDynamicReportEntry::CalculatableSection(section) => {
let updated_section = section.clone().calculate(&report);
drop(entry_ref); // Drop entry_ref so we can borrow mutably
let mut entry_mut = self.entries[entry_idx].borrow_mut();
*entry_mut = CalculatableDynamicReportEntry::Section(updated_section.clone());
calculated_entries.push(DynamicReportEntry::Section(updated_section));
}
CalculatableDynamicReportEntry::Section(section) => {
calculated_entries.push(DynamicReportEntry::Section(section.clone()));
}
CalculatableDynamicReportEntry::LiteralRow(row) => {
calculated_entries.push(DynamicReportEntry::LiteralRow(row.clone()));
}
CalculatableDynamicReportEntry::CalculatedRow(row) => {
let updated_row = row.calculate(&report);
drop(entry_ref); // Drop entry_ref so we can borrow mutably
let mut entry_mut = self.entries[entry_idx].borrow_mut();
*entry_mut = CalculatableDynamicReportEntry::LiteralRow(updated_row.clone());
calculated_entries.push(DynamicReportEntry::LiteralRow(updated_row));
}
CalculatableDynamicReportEntry::Spacer => (),
}
}
Section {
text: self.text.clone(),
id: self.id.clone(),
visible: self.visible,
auto_hide: self.auto_hide,
entries: calculated_entries,
}
}
/// Look up [CalculatableDynamicReportEntry] by id
///
/// Returns a cloned copy of the [CalculatableDynamicReportEntry].
pub fn by_id(&self, id: &str) -> Option<CalculatableDynamicReportEntry> {
// Manually iterate over self.entries rather than self.entries()
// To catch the situation where entry is already mutably borrowed
for entry in self.entries.iter() {
match entry.try_borrow() {
Ok(entry) => match &*entry {
CalculatableDynamicReportEntry::CalculatableSection(section) => {
if let Some(i) = &section.id {
if i == id {
return Some(entry.clone());
}
}
if let Some(e) = section.by_id(id) {
return Some(e);
}
}
CalculatableDynamicReportEntry::Section(_) => todo!(),
CalculatableDynamicReportEntry::LiteralRow(row) => {
if let Some(i) = &row.id {
if i == id {
return Some(entry.clone());
}
}
}
CalculatableDynamicReportEntry::CalculatedRow(_) => (),
CalculatableDynamicReportEntry::Spacer => (),
},
Err(err) => panic!(
"Attempt to call by_id on DynamicReportEntry which is mutably borrowed: {}",
err
),
}
}
None
}
/// Calculate the subtotals for this [CalculatableSection]
pub fn subtotal(&self, report: &CalculatableDynamicReport) -> Vec<QuantityInt> {
let mut subtotals = vec![0; report.columns.len()];
for entry in self.entries.iter() {
match &*entry.borrow() {
CalculatableDynamicReportEntry::CalculatableSection(section) => {
for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() {
subtotals[col_idx] += subtotal;
}
}
CalculatableDynamicReportEntry::Section(section) => {
for (col_idx, subtotal) in section.subtotal(report).into_iter().enumerate() {
subtotals[col_idx] += subtotal;
}
}
CalculatableDynamicReportEntry::LiteralRow(row) => {
for (col_idx, subtotal) in row.quantity.iter().enumerate() {
subtotals[col_idx] += subtotal;
}
}
CalculatableDynamicReportEntry::CalculatedRow(_) => (),
CalculatableDynamicReportEntry::Spacer => (),
}
}
subtotals
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
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::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::Spacer => true,
})
}
/// Look up [DynamicReportEntry] by id
///
/// Returns a cloned copy of the [DynamicReportEntry].
pub fn by_id(&self, id: &str) -> Option<DynamicReportEntry> {
// Manually iterate over self.entries rather than self.entries()
// To catch the situation where entry is already mutably borrowed
for entry in self.entries.iter() {
match entry {
DynamicReportEntry::Section(section) => {
if let Some(i) = &section.id {
if i == id {
return Some(entry.clone());
}
}
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.clone());
}
}
}
DynamicReportEntry::Spacer => (),
}
}
None
}
/// Calculate the subtotals for this [Section]
pub fn subtotal(&self, report: &CalculatableDynamicReport) -> 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::Spacer => (),
}
}
subtotals
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
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: &CalculatableDynamicReport) -> 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: &CalculatableDynamicReport) -> 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<CalculatableDynamicReportEntry> {
// 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<_>>();
// Some exceptions for the link
let link;
if account == crate::CURRENT_YEAR_EARNINGS {
link = Some("/income-statement".to_string());
} else if account == crate::RETAINED_EARNINGS {
link = None
} else {
link = Some(format!("/transactions/{}", account));
}
let entry = LiteralRow {
text: account.to_string(),
quantity: quantities,
id: None,
visible: true,
auto_hide: true,
link,
heading: false,
bordered: false,
};
entries.push(CalculatableDynamicReportEntry::LiteralRow(entry));
}
entries
}

View File

@ -0,0 +1,120 @@
/*
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::sync::Arc;
use tokio::{sync::RwLock, task::JoinSet};
use super::{
calculator::{would_be_ready_to_execute, ReportingGraphDependencies},
types::{ReportingContext, ReportingProducts, ReportingStep},
};
#[derive(Debug)]
pub enum ReportingExecutionError {
DependencyNotAvailable { message: String },
}
async fn execute_step(
step_idx: usize,
steps: Arc<Vec<Box<dyn ReportingStep>>>,
dependencies: Arc<ReportingGraphDependencies>,
context: Arc<ReportingContext>,
products: Arc<RwLock<ReportingProducts>>,
) -> (usize, Result<ReportingProducts, ReportingExecutionError>) {
let step = &steps[step_idx];
let result = step
.execute(&*context, &*steps, &*dependencies, &*products)
.await;
(step_idx, result)
}
pub async fn execute_steps(
steps: Vec<Box<dyn ReportingStep>>,
dependencies: ReportingGraphDependencies,
context: Arc<ReportingContext>,
) -> Result<ReportingProducts, ReportingExecutionError> {
let products = Arc::new(RwLock::new(ReportingProducts::new()));
// Prepare for async
let steps = Arc::new(steps);
let dependencies = Arc::new(dependencies);
// Execute steps asynchronously
let mut handles = JoinSet::new();
let mut steps_done = Vec::new();
let mut steps_remaining = (0..steps.len()).collect::<Vec<_>>();
while steps_done.len() != steps.len() {
// Execute each step which is ready to run
for step_idx in steps_remaining.iter().copied().collect::<Vec<_>>() {
// Check if ready to run
if would_be_ready_to_execute(&steps[step_idx], &steps, &dependencies, &steps_done) {
// Spawn new task
// Unfortunately the compiler cannot guarantee lifetimes are correct, so we must pass Arc across thread boundaries
handles.spawn(execute_step(
step_idx,
Arc::clone(&steps),
Arc::clone(&dependencies),
Arc::clone(&context),
Arc::clone(&products),
));
steps_remaining
.remove(steps_remaining.iter().position(|i| *i == step_idx).unwrap());
}
}
// Join next result
let (step_idx, result) = handles.join_next().await.unwrap().unwrap();
let step = &steps[step_idx];
steps_done.push(step_idx);
let mut new_products = result?;
// Sanity check the new products
for (product_id, _product) in new_products.map().iter() {
if product_id.name != step.id().name {
panic!(
"Unexpected product name {} from step {}",
product_id,
step.id()
);
}
if !step.id().product_kinds.contains(&product_id.kind) {
panic!(
"Unexpected product kind {} from step {}",
product_id,
step.id()
);
}
if product_id.args != step.id().args {
panic!(
"Unexpected product args {} from step {}",
product_id,
step.id()
);
}
}
// Insert the new products
products.write().await.append(&mut new_products);
}
Ok(Arc::into_inner(products).unwrap().into_inner())
}

View File

@ -0,0 +1,64 @@
/*
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::sync::Arc;
use calculator::{steps_for_targets, ReportingCalculationError};
use executor::{execute_steps, ReportingExecutionError};
use types::{ReportingContext, ReportingProductId, ReportingProducts};
pub mod builders;
pub mod calculator;
pub mod dynamic_report;
pub mod executor;
pub mod steps;
pub mod types;
#[derive(Debug)]
pub enum ReportingError {
ReportingCalculationError(ReportingCalculationError),
ReportingExecutionError(ReportingExecutionError),
}
impl From<ReportingCalculationError> for ReportingError {
fn from(err: ReportingCalculationError) -> Self {
ReportingError::ReportingCalculationError(err)
}
}
impl From<ReportingExecutionError> for ReportingError {
fn from(err: ReportingExecutionError) -> Self {
ReportingError::ReportingExecutionError(err)
}
}
/// Calculate the steps required to generate the requested [ReportingProductId]s and then execute them
///
/// Helper function to call [steps_for_targets] followed by [execute_steps].
pub async fn generate_report(
targets: Vec<ReportingProductId>,
context: Arc<ReportingContext>,
) -> Result<ReportingProducts, ReportingError> {
// Solve dependencies
let (sorted_steps, dependencies) = steps_for_targets(targets, &*context)?;
// Execute steps
let products = execute_steps(sorted_steps, dependencies, context).await?;
Ok(products)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,431 @@
/*
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 std::fmt::{Debug, Display};
use std::hash::Hash;
use async_trait::async_trait;
use chrono::NaiveDate;
use downcast_rs::Downcast;
use dyn_clone::DynClone;
use dyn_eq::DynEq;
use dyn_hash::DynHash;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use crate::db::DbConnection;
use crate::model::transaction::TransactionWithPostings;
use crate::QuantityInt;
use super::calculator::ReportingGraphDependencies;
use super::executor::ReportingExecutionError;
// -----------------
// REPORTING CONTEXT
/// Records the context for a single reporting job
pub struct ReportingContext {
// Configuration
pub db_connection: DbConnection,
pub eofy_date: NaiveDate,
pub reporting_commodity: String,
// State
pub(crate) step_lookup_fn: HashMap<
(&'static str, &'static [ReportingProductKind]),
(ReportingStepTakesArgsFn, ReportingStepFromArgsFn),
>,
pub(crate) step_dynamic_builders: Vec<ReportingStepDynamicBuilder>,
}
impl ReportingContext {
/// Initialise a new [ReportingContext]
pub fn new(
db_connection: DbConnection,
eofy_date: NaiveDate,
reporting_commodity: String,
) -> Self {
Self {
db_connection,
eofy_date,
reporting_commodity,
step_lookup_fn: HashMap::new(),
step_dynamic_builders: Vec::new(),
}
}
/// Register a lookup function
///
/// A lookup function generates concrete [ReportingStep]s from a [ReportingStepId].
pub fn register_lookup_fn(
&mut self,
name: &'static str,
product_kinds: &'static [ReportingProductKind],
takes_args_fn: ReportingStepTakesArgsFn,
from_args_fn: ReportingStepFromArgsFn,
) {
self.step_lookup_fn
.insert((name, product_kinds), (takes_args_fn, from_args_fn));
}
/// Register a dynamic builder
///
/// Dynamic builders are called when no concrete [ReportingStep] is implemented, and can dynamically generate a [ReportingStep]. Dynamic builders are implemented in [super::builders].
pub fn register_dynamic_builder(&mut self, builder: ReportingStepDynamicBuilder) {
if !self
.step_dynamic_builders
.iter()
.any(|b| b.name == builder.name)
{
self.step_dynamic_builders.push(builder);
}
}
}
/// Function which determines whether the [ReportingStepArgs] are valid arguments for a given [ReportingStep]
///
/// See [ReportingContext::register_lookup_fn].
pub type ReportingStepTakesArgsFn = fn(args: &Box<dyn ReportingStepArgs>) -> bool;
/// Function which builds a concrete [ReportingStep] from the given [ReportingStepArgs]
///
/// See [ReportingContext::register_lookup_fn].
pub type ReportingStepFromArgsFn = fn(args: Box<dyn ReportingStepArgs>) -> Box<dyn ReportingStep>;
// -------------------------------
// REPORTING STEP DYNAMIC BUILDERS
/// Represents a reporting step dynamic builder
///
/// See [ReportingContext::register_dynamic_builder].
pub struct ReportingStepDynamicBuilder {
pub name: &'static str,
pub can_build: fn(
name: &'static str,
kind: ReportingProductKind,
args: &Box<dyn ReportingStepArgs>,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
context: &ReportingContext,
) -> bool,
pub build: fn(
name: &'static str,
kind: ReportingProductKind,
args: Box<dyn ReportingStepArgs>,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
context: &ReportingContext,
) -> Box<dyn ReportingStep>,
}
// ------------------
// REPORTING PRODUCTS
/// Identifies a [ReportingProduct]
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ReportingProductId {
pub name: &'static str,
pub kind: ReportingProductKind,
pub args: Box<dyn ReportingStepArgs>,
}
impl Display for ReportingProductId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{}.{:?}({})", self.name, self.kind, self.args))
}
}
/// Identifies a type of [ReportingProduct]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ReportingProductKind {
Transactions,
BalancesAt,
BalancesBetween,
Generic,
}
/// Represents the result of a [ReportingStep]
pub trait ReportingProduct: Debug + Downcast + DynClone + Send + Sync {}
downcast_rs::impl_downcast!(ReportingProduct);
dyn_clone::clone_trait_object!(ReportingProduct);
/// Records a list of transactions generated by a [ReportingStep]
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Transactions {
pub transactions: Vec<TransactionWithPostings>,
}
impl ReportingProduct for Transactions {}
/// Records cumulative account balances at a particular point in time
#[derive(Clone, Debug)]
pub struct BalancesAt {
pub balances: HashMap<String, QuantityInt>,
}
impl ReportingProduct for BalancesAt {}
/// Records the total value of transactions in each account between two points in time
#[derive(Clone, Debug)]
pub struct BalancesBetween {
pub balances: HashMap<String, QuantityInt>,
}
impl ReportingProduct for BalancesBetween {}
/// Map from [ReportingProductId] to [ReportingProduct]
#[derive(Clone, Debug)]
pub struct ReportingProducts {
// This needs to be an IndexMap not HashMap, because sometimes we query which product is more up to date
map: IndexMap<ReportingProductId, Box<dyn ReportingProduct>>,
}
impl ReportingProducts {
pub fn new() -> Self {
Self {
map: IndexMap::new(),
}
}
/// Returns a reference to the underlying [IndexMap]
pub fn map(&self) -> &IndexMap<ReportingProductId, Box<dyn ReportingProduct>> {
&self.map
}
/// Insert a key-value pair in the map
///
/// See [IndexMap::insert].
pub fn insert(&mut self, key: ReportingProductId, value: Box<dyn ReportingProduct>) {
self.map.insert(key, value);
}
/// Moves all key-value pairs from `other` into `self`, leaving `other` empty
///
/// See [IndexMap::append].
pub fn append(&mut self, other: &mut ReportingProducts) {
self.map.append(&mut other.map);
}
pub fn get_or_err(
&self,
key: &ReportingProductId,
) -> Result<&Box<dyn ReportingProduct>, ReportingExecutionError> {
match self.map.get(key) {
Some(value) => Ok(value),
None => Err(ReportingExecutionError::DependencyNotAvailable {
message: format!("Product {} not available when expected", key),
}),
}
}
pub fn get_owned_or_err(
mut self,
key: &ReportingProductId,
) -> Result<Box<dyn ReportingProduct>, ReportingExecutionError> {
match self.map.swap_remove(key) {
Some(value) => Ok(value),
None => Err(ReportingExecutionError::DependencyNotAvailable {
message: format!("Product {} not available when expected", key),
}),
}
}
}
impl Display for ReportingProducts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"ReportingProducts {{\n{}\n}}",
self.map
.iter()
.map(|(k, v)| format!(" {}: {:?}", k, v))
.collect::<Vec<_>>()
.join(",\n")
))
}
}
// ---------------
// REPORTING STEPS
/// Identifies a [ReportingStep]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReportingStepId {
pub name: &'static str,
pub product_kinds: &'static [ReportingProductKind],
pub args: Box<dyn ReportingStepArgs>,
}
impl Display for ReportingStepId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"{}{:?}({})",
self.name, self.product_kinds, self.args
))
}
}
/// Represents a step in a reporting job
#[async_trait]
pub trait ReportingStep: Debug + Display + Downcast + Send + Sync {
/// Get the [ReportingStepId] for this [ReportingStep]
fn id(&self) -> ReportingStepId;
/// Return a list of statically defined dependencies for this [ReportingStep]
#[allow(unused_variables)]
fn requires(&self, context: &ReportingContext) -> Vec<ReportingProductId> {
vec![]
}
/// Called when the [ReportingStep] is initialised in [super::calculator::steps_for_targets]
#[allow(unused_variables)]
fn init_graph(
&self,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &mut ReportingGraphDependencies,
context: &ReportingContext,
) {
}
/// Called when new [ReportingStep]s are initialised in [super::calculator::steps_for_targets]
///
/// This callback can be used to dynamically declare dependencies between [ReportingStep]s that are not known at initialisation.
#[allow(unused_variables)]
fn after_init_graph(
&self,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &mut ReportingGraphDependencies,
context: &ReportingContext,
) {
}
/// Called to generate the [ReportingProduct] for this [ReportingStep]
///
/// Returns a [ReportingProducts] containing (only) the new [ReportingProduct]s.
#[allow(unused_variables)]
async fn execute(
&self,
context: &ReportingContext,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &ReportingGraphDependencies,
products: &RwLock<ReportingProducts>,
) -> Result<ReportingProducts, ReportingExecutionError> {
todo!("{}", self);
}
}
downcast_rs::impl_downcast!(ReportingStep);
// ------------------------
// REPORTING STEP ARGUMENTS
/// Represents arguments to a [ReportingStep]
pub trait ReportingStepArgs:
Debug + Display + Downcast + DynClone + DynEq + DynHash + Send + Sync
{
}
downcast_rs::impl_downcast!(ReportingStepArgs);
dyn_clone::clone_trait_object!(ReportingStepArgs);
dyn_eq::eq_trait_object!(ReportingStepArgs);
dyn_hash::hash_trait_object!(ReportingStepArgs);
/// [ReportingStepArgs] implementation which takes no arguments
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct VoidArgs {}
impl ReportingStepArgs for VoidArgs {}
impl Display for VoidArgs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(""))
}
}
/// [ReportingStepArgs] implementation which takes a single date
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct DateArgs {
pub date: NaiveDate,
}
impl ReportingStepArgs for DateArgs {}
impl Display for DateArgs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{}", self.date))
}
}
/// [ReportingStepArgs] implementation which takes a date range
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct DateStartDateEndArgs {
pub date_start: NaiveDate,
pub date_end: NaiveDate,
}
impl ReportingStepArgs for DateStartDateEndArgs {}
impl Display for DateStartDateEndArgs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
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(", ")
))
}
}

62
libdrcr/src/serde.rs Normal file
View File

@ -0,0 +1,62 @@
/*
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/>.
*/
/// Serialises [chrono::NaiveDateTime] in database format
///
/// Use as `#[serde(with = "crate::serde::naivedatetime_to_js")]`, etc.
pub mod naivedatetime_to_js {
use std::fmt;
use chrono::NaiveDateTime;
use serde::{
de::{self, Unexpected, Visitor},
Deserializer, Serializer,
};
pub(crate) fn serialize<S: Serializer>(
dt: &NaiveDateTime,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string())
}
struct DateVisitor;
impl<'de> Visitor<'de> for DateVisitor {
type Value = NaiveDateTime;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
write!(formatter, "a date string")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
match NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.6f") {
Ok(dt) => Ok(dt),
Err(_) => Err(de::Error::invalid_value(Unexpected::Str(s), &self)),
}
}
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<NaiveDateTime, D::Error> {
deserializer.deserialize_str(DateVisitor)
}
}

43
libdrcr/src/util.rs Normal file
View File

@ -0,0 +1,43 @@
/*
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 chrono::{Datelike, NaiveDate};
/// Return the end date of the current financial year for the given date
pub fn get_eofy(date: &NaiveDate, eofy_date: &NaiveDate) -> NaiveDate {
let date_eofy = eofy_date.with_year(date.year()).unwrap();
if date_eofy >= *date {
date_eofy
} else {
date_eofy.with_year(date_eofy.year() + 1).unwrap()
}
}
/// Return the start date of the financial year, given the end date of the financial year
pub fn sofy_from_eofy(eofy_date: NaiveDate) -> NaiveDate {
eofy_date
.with_year(eofy_date.year() - 1)
.unwrap()
.succ_opt()
.unwrap()
}
/// Format the [NaiveDate] as a string
pub fn format_date(date: NaiveDate) -> String {
date.format("%Y-%m-%d 00:00:00.000000").to_string()
}

1
rustfmt.toml Normal file
View File

@ -0,0 +1 @@
hard_tabs = true

106
src-tauri/Cargo.lock generated
View File

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@ -211,9 +211,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.83"
version = "0.1.88"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [
"proc-macro2",
"quote",
@ -514,15 +514,17 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.38"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"windows-targets 0.52.6",
"wasm-bindgen",
"windows-link",
]
[[package]]
@ -914,6 +916,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "downcast-rs"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf"
[[package]]
name = "dpi"
version = "0.1.1"
@ -927,7 +935,9 @@ dependencies = [
name = "drcr"
version = "0.1.0"
dependencies = [
"indexmap 2.6.0",
"chrono",
"indexmap 2.9.0",
"libdrcr",
"serde",
"serde_json",
"sqlx",
@ -963,9 +973,21 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.17"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125"
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
[[package]]
name = "dyn-eq"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388"
[[package]]
name = "dyn-hash"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88"
[[package]]
name = "either"
@ -1950,9 +1972,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.6.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown 0.15.1",
@ -2149,9 +2171,26 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.162"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libdrcr"
version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"downcast-rs 2.0.1",
"dyn-clone",
"dyn-eq",
"dyn-hash",
"indexmap 2.9.0",
"serde",
"serde_json",
"sqlx",
"tokio",
]
[[package]]
name = "libloading"
@ -3022,7 +3061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
dependencies = [
"base64 0.22.1",
"indexmap 2.6.0",
"indexmap 2.9.0",
"quick-xml 0.32.0",
"serde",
"time",
@ -3492,9 +3531,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.215"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
@ -3512,9 +3551,9 @@ dependencies = [
[[package]]
name = "serde_derive"
version = "1.0.215"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
@ -3534,9 +3573,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.132"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa 1.0.11",
"memchr",
@ -3586,7 +3625,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.6.0",
"indexmap 2.9.0",
"serde",
"serde_derive",
"serde_json",
@ -3846,7 +3885,7 @@ dependencies = [
"hashbrown 0.14.5",
"hashlink",
"hex",
"indexmap 2.6.0",
"indexmap 2.9.0",
"log",
"memchr",
"once_cell",
@ -4390,7 +4429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb7022ccbcd1799dcf7acb97805f1a5fc7f046b4cf67518296a2e8921bab613a"
dependencies = [
"futures-core",
"indexmap 2.6.0",
"indexmap 2.9.0",
"log",
"serde",
"serde_json",
@ -4638,14 +4677,15 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.41.1"
version = "1.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
@ -4656,9 +4696,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.4.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
@ -4728,7 +4768,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.6.0",
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime",
@ -4741,7 +4781,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [
"indexmap 2.6.0",
"indexmap 2.9.0",
"serde",
"serde_spanned",
"toml_datetime",
@ -5130,7 +5170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6"
dependencies = [
"cc",
"downcast-rs",
"downcast-rs 1.2.1",
"rustix",
"scoped-tls",
"smallvec",
@ -5382,6 +5422,12 @@ dependencies = [
"syn 2.0.87",
]
[[package]]
name = "windows-link"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-registry"
version = "0.2.0"

View File

@ -18,7 +18,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
chrono = "0.4.41"
indexmap = { version = "2", features = ["serde"] }
libdrcr = { path = "../libdrcr" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["json", "time"] }

View File

@ -1,21 +1,22 @@
/*
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 Lee Yingtong Li (RunasSudo)
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/>.
*/
mod libdrcr_bridge;
mod sql;
use tauri::{AppHandle, Builder, Manager, State};
@ -32,20 +33,26 @@ struct AppState {
// Filename state
#[tauri::command]
async fn get_open_filename(state: State<'_, Mutex<AppState>>) -> Result<Option<String>, tauri_plugin_sql::Error> {
async fn get_open_filename(
state: State<'_, Mutex<AppState>>,
) -> Result<Option<String>, tauri_plugin_sql::Error> {
let state = state.lock().await;
Ok(state.db_filename.clone())
}
#[tauri::command]
async fn set_open_filename(state: State<'_, Mutex<AppState>>, app: AppHandle, filename: Option<String>) -> Result<(), tauri_plugin_sql::Error> {
async fn set_open_filename(
state: State<'_, Mutex<AppState>>,
app: AppHandle,
filename: Option<String>,
) -> Result<(), tauri_plugin_sql::Error> {
let mut state = state.lock().await;
state.db_filename = filename.clone();
// Persist in store
let store = app.store("store.json").expect("Error opening store");
store.set("db_filename", filename);
Ok(())
}
@ -65,15 +72,15 @@ pub fn run() {
} else {
None
}
},
_ => panic!("Unexpected db_filename in store")
}
_ => panic!("Unexpected db_filename in store"),
};
app.manage(Mutex::new(AppState {
db_filename: db_filename,
sql_transactions: Vec::new(),
}));
Ok(())
})
.plugin(tauri_plugin_dialog::init())
@ -81,8 +88,19 @@ pub fn run() {
.plugin(tauri_plugin_sql::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build())
.invoke_handler(tauri::generate_handler![
get_open_filename, set_open_filename,
sql::sql_transaction_begin, sql::sql_transaction_execute, sql::sql_transaction_select, sql::sql_transaction_rollback, sql::sql_transaction_commit
get_open_filename,
set_open_filename,
libdrcr_bridge::get_all_transactions_except_earnings_to_equity,
libdrcr_bridge::get_all_transactions_except_earnings_to_equity_for_account,
libdrcr_bridge::get_balance_sheet,
libdrcr_bridge::get_income_statement,
libdrcr_bridge::get_trial_balance,
libdrcr_bridge::get_validated_balance_assertions,
sql::sql_transaction_begin,
sql::sql_transaction_execute,
sql::sql_transaction_select,
sql::sql_transaction_rollback,
sql::sql_transaction_commit
])
.run(tauri::generate_context!())
.expect("Error while running tauri application");

View File

@ -0,0 +1,284 @@
/*
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::HashSet;
use std::sync::Arc;
use chrono::NaiveDate;
use libdrcr::db::DbConnection;
use libdrcr::model::assertions::BalanceAssertion;
use libdrcr::reporting::builders::register_dynamic_builders;
use libdrcr::reporting::dynamic_report::DynamicReport;
use libdrcr::reporting::generate_report;
use libdrcr::reporting::steps::register_lookup_fns;
use libdrcr::reporting::types::{
BalancesAt, DateArgs, DateStartDateEndArgs, MultipleDateArgs, MultipleDateStartDateEndArgs,
ReportingContext, ReportingProduct, ReportingProductId, ReportingProductKind, Transactions,
VoidArgs,
};
use serde::{Deserialize, Serialize};
use tauri::State;
use tokio::sync::Mutex;
use crate::AppState;
async fn get_report(
state: State<'_, Mutex<AppState>>,
target: &ReportingProductId,
) -> Box<dyn ReportingProduct> {
let state = state.lock().await;
let db_filename = state.db_filename.clone().unwrap();
// Connect to database
let db_connection =
DbConnection::new(format!("sqlite:{}", db_filename.as_str()).as_str()).await;
// Initialise ReportingContext
let eofy_date = db_connection.metadata().eofy_date;
let mut context = ReportingContext::new(db_connection, eofy_date, "$".to_string());
register_lookup_fns(&mut context);
register_dynamic_builders(&mut context);
// Get dynamic report
let targets = vec![
ReportingProductId {
name: "CalculateIncomeTax",
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
},
target.clone(),
];
let products = generate_report(targets, Arc::new(context)).await.unwrap();
let result = products.get_owned_or_err(&target).unwrap();
result
}
#[tauri::command]
pub(crate) async fn get_all_transactions_except_earnings_to_equity(
state: State<'_, Mutex<AppState>>,
) -> Result<String, ()> {
let transactions = get_report(
state,
&ReportingProductId {
name: "AllTransactionsExceptEarningsToEquity",
kind: ReportingProductKind::Transactions,
args: Box::new(DateArgs {
date: NaiveDate::from_ymd_opt(9999, 12, 31).unwrap(),
}),
},
)
.await
.downcast::<Transactions>()
.unwrap()
.transactions;
Ok(serde_json::to_string(&transactions).unwrap())
}
#[tauri::command]
pub(crate) async fn get_all_transactions_except_earnings_to_equity_for_account(
state: State<'_, Mutex<AppState>>,
account: String,
) -> Result<String, ()> {
let transactions = get_report(
state,
&ReportingProductId {
name: "AllTransactionsExceptEarningsToEquity",
kind: ReportingProductKind::Transactions,
args: Box::new(DateArgs {
date: NaiveDate::from_ymd_opt(9999, 12, 31).unwrap(),
}),
},
)
.await
.downcast::<Transactions>()
.unwrap()
.transactions;
// Filter only transactions affecting this account
let filtered_transactions = transactions
.into_iter()
.filter(|t| t.postings.iter().any(|p| p.account == account))
.collect::<Vec<_>>();
Ok(serde_json::to_string(&filtered_transactions).unwrap())
}
#[tauri::command]
pub(crate) async fn get_balance_sheet(
state: State<'_, Mutex<AppState>>,
dates: Vec<String>,
) -> Result<String, ()> {
let mut date_args = Vec::new();
for date in dates.iter() {
date_args.push(DateArgs {
date: NaiveDate::parse_from_str(date, "%Y-%m-%d").expect("Invalid date"),
})
}
Ok(get_report(
state,
&ReportingProductId {
name: "BalanceSheet",
kind: ReportingProductKind::Generic,
args: Box::new(MultipleDateArgs {
dates: date_args.clone(),
}),
},
)
.await
.downcast_ref::<DynamicReport>()
.unwrap()
.to_json())
}
#[tauri::command]
pub(crate) async fn get_income_statement(
state: State<'_, Mutex<AppState>>,
dates: Vec<(String, String)>,
) -> Result<String, ()> {
let mut date_args = Vec::new();
for (date_start, date_end) in dates.iter() {
date_args.push(DateStartDateEndArgs {
date_start: NaiveDate::parse_from_str(date_start, "%Y-%m-%d").expect("Invalid date"),
date_end: NaiveDate::parse_from_str(date_end, "%Y-%m-%d").expect("Invalid date"),
})
}
Ok(get_report(
state,
&ReportingProductId {
name: "IncomeStatement",
kind: ReportingProductKind::Generic,
args: Box::new(MultipleDateStartDateEndArgs {
dates: date_args.clone(),
}),
},
)
.await
.downcast_ref::<DynamicReport>()
.unwrap()
.to_json())
}
#[tauri::command]
pub(crate) async fn get_trial_balance(
state: State<'_, Mutex<AppState>>,
date: String,
) -> Result<String, ()> {
let date = NaiveDate::parse_from_str(&date, "%Y-%m-%d").expect("Invalid date");
Ok(get_report(
state,
&ReportingProductId {
name: "TrialBalance",
kind: ReportingProductKind::Generic,
args: Box::new(DateArgs { date }),
},
)
.await
.downcast_ref::<DynamicReport>()
.unwrap()
.to_json())
}
#[derive(Deserialize, Serialize)]
struct ValidatedBalanceAssertion {
#[serde(flatten)]
assertion: BalanceAssertion,
is_valid: bool,
}
#[tauri::command]
pub(crate) async fn get_validated_balance_assertions(
state: State<'_, Mutex<AppState>>,
) -> Result<String, ()> {
let state = state.lock().await;
let db_filename = state.db_filename.clone().unwrap();
// Connect to database
let db_connection =
DbConnection::new(format!("sqlite:{}", db_filename.as_str()).as_str()).await;
let reporting_commodity = db_connection.metadata().reporting_commodity.clone(); // Needed later
// First get balance assertions from database
let balance_assertions = db_connection.get_balance_assertions().await;
// Get dates of balance assertions
let dates = balance_assertions
.iter()
.map(|b| b.dt)
.collect::<HashSet<_>>();
// Initialise ReportingContext
let eofy_date = db_connection.metadata().eofy_date;
let mut context = ReportingContext::new(db_connection, eofy_date, "$".to_string());
register_lookup_fns(&mut context);
register_dynamic_builders(&mut context);
// Get report targets
let mut targets = vec![ReportingProductId {
name: "CalculateIncomeTax",
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
}];
for dt in dates {
// Request ordinary transaction balances at each balance assertion date
targets.push(ReportingProductId {
name: "CombineOrdinaryTransactions",
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs { date: dt.date() }),
});
}
// Run report
let products = generate_report(targets, Arc::new(context)).await.unwrap();
// Validate each balance assertion
let mut validated_assertions = Vec::new();
for balance_assertion in balance_assertions {
let balances_at_date = products
.get_or_err(&ReportingProductId {
name: "CombineOrdinaryTransactions",
kind: ReportingProductKind::BalancesAt,
args: Box::new(DateArgs {
date: balance_assertion.dt.date(),
}),
})
.unwrap()
.downcast_ref::<BalancesAt>()
.unwrap();
let account_balance = *balances_at_date
.balances
.get(&balance_assertion.account)
.unwrap_or(&0);
let is_valid = balance_assertion.quantity == account_balance
&& balance_assertion.commodity == reporting_commodity;
validated_assertions.push(ValidatedBalanceAssertion {
assertion: balance_assertion,
is_valid,
});
}
Ok(serde_json::to_string(&validated_assertions).unwrap())
}

View File

@ -55,8 +55,6 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { defineProps } from 'vue';
import { DT_FORMAT, db, deserialiseAmount } from '../db.ts';
import ComboBoxAccounts from './ComboBoxAccounts.vue';

View File

@ -1,6 +1,6 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 Lee Yingtong Li (RunasSudo)
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
@ -44,7 +44,7 @@
<script setup lang="ts">
import { ChevronUpDownIcon } from '@heroicons/vue/24/outline';
import { defineModel, defineProps, useTemplateRef } from 'vue';
import { useTemplateRef } from 'vue';
const { values, inputClass } = defineProps<{ values: string[], inputClass?: string }>();
const inputField = useTemplateRef('inputField');

View File

@ -21,7 +21,7 @@
</template>
<script setup lang="ts">
import { defineProps, ref } from 'vue';
import { ref } from 'vue';
import { db } from '../db.ts';
import ComboBox from './ComboBox.vue';

View File

@ -1,63 +0,0 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222025 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/>.
-->
<template>
<template v-if="reports.length > 0">
<h1 class="page-heading">
{{ reports[0].title }}
</h1>
<slot />
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-300">
<th></th>
<th v-for="label of labels" class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ label }}&nbsp;</th>
</tr>
</thead>
<tbody>
<ComparativeDynamicReportEntry :row="[row[0], row]" v-for="row of joinedEntries" />
</tbody>
</table>
</template>
</template>
<script setup lang="ts">
import { computed, defineProps } from 'vue';
import { DynamicReport } from '../reports/base.ts';
import ComparativeDynamicReportEntry from './ComparativeDynamicReportEntry.vue';
const { reports, labels } = defineProps<{ reports: DynamicReport[], labels: string[] }>();
const joinedEntries = computed(() => {
// FIXME: Validate reports are of the same type, etc.
const result = [];
for (let i = 0; i < reports[0].entries.length; i++) {
const thisRow = [];
for (let report of reports) {
thisRow.push(report.entries[i]);
}
result.push(thisRow);
}
return result;
});
</script>

View File

@ -1,106 +0,0 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222025 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/>.
-->
<template>
<template v-if="row[0] instanceof Entry">
<!-- NB: Subtotal and Calculated are subclasses of Entry -->
<tr :class="row[0].bordered ? 'border-y border-gray-300' : null">
<component :is="row[0].heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': row[0].heading }">
<a :href="row[0].link" class="hover:text-blue-700 hover:underline" v-if="row[0].link !== null">{{ row[0].text }}</a>
<template v-if="row[0].link === null">{{ row[0].text }}</template>
</component>
<template v-for="entry of row[1]">
<component :is="row[0].heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': row[0].heading }" v-html="entry ? ppBracketed((entry as Entry).quantity, (entry as Entry).link ?? undefined) : ''" />
</template>
</tr>
</template>
<template v-if="row[0] instanceof Section">
<tr v-if="row[0].title !== null">
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ row[0].title }}</th>
<th></th>
</tr>
<ComparativeDynamicReportEntry :row="childRow" v-for="childRow of joinedChildren" />
</template>
<template v-if="row[0] instanceof Spacer">
<tr><td :colspan="row[1].length + 1" class="py-0.5">&nbsp;</td></tr>
</template>
</template>
<script setup lang="ts">
import { computed, defineProps } from 'vue';
import { ppBracketed } from '../display.ts';
import { DynamicReportNode, Entry, Section, Spacer, Subtotal } from '../reports/base.ts';
const { row } = defineProps<{ row: [DynamicReportNode, (DynamicReportNode | null)[]] }>();
const joinedChildren = computed(() => {
// First get all children's names
const joinedNames: string[] = [];
for (let cell of row[1]) {
for (let entry of (cell as any).entries) {
if (entry instanceof Subtotal) { // Handle Subtotal separately
continue;
}
if (!joinedNames.includes((entry as any).text)) {
joinedNames.push((entry as any).text);
}
}
}
joinedNames.sort();
// Then return joined children in order of sorted names
const result: [DynamicReportNode, (DynamicReportNode | null)[]][] = [];
for (let name of joinedNames) {
const thisRow: DynamicReportNode[] = [];
let thisRowExample = null;
for (let cell of row[1]) {
let thisCell = null;
for (let entry of (cell as any).entries) {
if ((entry as any).text === name) {
thisCell = entry;
thisRowExample = entry;
break;
}
}
thisRow.push(thisCell);
}
result.push([thisRowExample, thisRow]);
}
// Add Subtotal
const subtotalRow = [];
let subtotalExample = null;
for (let cell of row[1]) {
let thisCell = null;
for (let entry of (cell as any).entries) {
if (entry instanceof Subtotal) {
thisCell = entry;
subtotalExample = entry;
break;
}
}
subtotalRow.push(thisCell);
}
if (subtotalExample) {
result.push([subtotalExample, subtotalRow]);
}
return result;
});
</script>

View File

@ -1,6 +1,6 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222025 Lee Yingtong Li (RunasSudo)
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
@ -43,7 +43,7 @@
<script setup lang="ts">
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/24/outline';
import { defineModel, defineProps, ref } from 'vue';
import { ref } from 'vue';
const { values } = defineProps<{ values: [string | null, [string, string][]][] }>(); // Array of [category name, [internal identifier, pretty name]]

View File

@ -1,6 +1,6 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 Lee Yingtong Li (RunasSudo)
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
@ -28,22 +28,19 @@
<thead>
<tr class="border-b border-gray-300">
<th></th>
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ db.metadata.reporting_commodity }}&nbsp;</th>
<th v-for="column of report.columns" class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ column }}&nbsp;</th>
</tr>
</thead>
<tbody>
<DynamicReportEntry :entry="entry" v-for="entry of report.entries" />
<DynamicReportEntryComponent :entry="entry" v-for="entry of report.entries" />
</tbody>
</table>
</template>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
import { db } from '../db.ts';
import { DynamicReport } from '../reports/base.ts';
import DynamicReportEntry from './DynamicReportEntry.vue';
import DynamicReportEntryComponent from './DynamicReportEntryComponent.vue';
const { report } = defineProps<{ report: DynamicReport | null }>();
</script>

View File

@ -1,49 +0,0 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 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/>.
-->
<template>
<template v-if="entry instanceof Entry">
<!-- NB: Subtotal and Calculated are subclasses of Entry -->
<tr :class="entry.bordered ? 'border-y border-gray-300' : null">
<component :is="entry.heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': entry.heading }">
<a :href="entry.link" class="hover:text-blue-700 hover:underline" v-if="entry.link !== null">{{ entry.text }}</a>
<template v-if="entry.link === null">{{ entry.text }}</template>
</component>
<component :is="entry.heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': entry.heading }" v-html="ppBracketed(entry.quantity, entry.link ?? undefined)" />
</tr>
</template>
<template v-if="entry instanceof Section">
<tr v-if="entry.title !== null">
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ entry.title }}</th>
<th></th>
</tr>
<DynamicReportEntry :entry="child" v-for="child of entry.entries" />
</template>
<template v-if="entry instanceof Spacer">
<tr><td colspan="2" class="py-0.5">&nbsp;</td></tr>
</template>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
import { ppBracketed } from '../display.ts';
import { DynamicReportNode, Entry, Section, Spacer } from '../reports/base.ts';
const { entry } = defineProps<{ entry: DynamicReportNode }>();
</script>

View File

@ -0,0 +1,57 @@
<!--
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.
import { db } from '../db.ts';
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/>.
-->
<template>
<template v-if="literalRow">
<tr :class="literalRow.bordered ? 'border-y border-gray-300' : null">
<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': literalRow.heading }">
<a :href="literalRow.link as string" class="hover:text-blue-700 hover:underline" v-if="literalRow.link !== null">{{ literalRow.text }}</a>
<template v-if="literalRow.link === null">{{ literalRow.text }}</template>
</component>
<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': literalRow.heading }" v-html="(cell !== 0 || literalRow.heading) ? ppBracketed(cell, literalRow.link ?? undefined) : ''" v-for="cell of literalRow.quantity">
</component>
</tr>
</template>
<template v-if="section">
<tr v-if="section.text !== null">
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ section.text }}</th>
<th></th><!-- FIXME: Have correct colspan -->
</tr>
<DynamicReportEntryComponent :entry="child" v-for="child of section.entries" />
</template>
<template v-if="entry == 'Spacer'">
<tr><td colspan="2" class="py-0.5">&nbsp;</td></tr><!-- FIXME: Have correct colspan -->
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { ppBracketed } from '../display.ts';
import { DynamicReportEntry, LiteralRow, Section } from '../reports/base.ts';
const { entry } = defineProps<{ entry: DynamicReportEntry }>();
const literalRow = computed(function() {
return (entry as { LiteralRow: LiteralRow }).LiteralRow;
});
const section = computed(function() {
return (entry as { Section: Section }).Section;
});
</script>

View File

@ -1,6 +1,6 @@
/*
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 Lee Yingtong Li (RunasSudo)
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
@ -43,7 +43,7 @@ async function initApp() {
{ path: '/statement-lines', name: 'statement-lines', component: () => import('./pages/StatementLinesView.vue') },
{ path: '/statement-lines/import', name: 'import-statement', component: () => import('./pages/ImportStatementView.vue') },
{ path: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') },
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') },
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./reports/TrialBalanceReport.vue') },
];
const router = createRouter({
history: createWebHistory(),

View File

@ -1,6 +1,6 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222025 Lee Yingtong Li (RunasSudo)
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
@ -48,8 +48,8 @@
<td class="py-0.5 px-1 text-gray-900 text-end">{{ pp(Math.abs(assertion.quantity)) }}</td>
<td class="py-0.5 pr-1 text-gray-900">{{ assertion.quantity >= 0 ? 'Dr' : 'Cr' }}</td>
<td class="py-0.5 px-1 text-gray-900">
<CheckIcon class="w-4 h-4" v-if="assertion.isValid" />
<XMarkIcon class="w-4 h-4 text-red-500" v-if="!assertion.isValid" />
<CheckIcon class="w-4 h-4" v-if="assertion.is_valid" />
<XMarkIcon class="w-4 h-4 text-red-500" v-if="!assertion.is_valid" />
</td>
<td class="py-0.5 pl-1 text-gray-900 text-end">
<a :href="'/balance-assertions/edit/' + assertion.id" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">
@ -63,14 +63,12 @@
<script setup lang="ts">
import dayjs from 'dayjs';
import { ref } from 'vue';
import { db } from '../db.ts';
import { pp } from '../display.ts';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { invoke } from '@tauri-apps/api/core';
import { ref } from 'vue';
import { pp } from '../display.ts';
const balanceAssertions = ref([] as ValidatedBalanceAssertion[]);
@ -81,42 +79,11 @@
account: string,
quantity: number,
commodity: string,
isValid: boolean,
is_valid: boolean,
}
async function load() {
const session = await db.load();
const rawBalanceAssertions: any[] = await session.select(
`SELECT *
FROM balance_assertions
ORDER BY dt DESC, id DESC`
);
// Get transactions
const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session);
const transactions = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions);
for (const balanceAssertion of rawBalanceAssertions) {
// Check assertion status
const balanceAssertionDt = dayjs(balanceAssertion.dt);
let accountBalance = 0;
for (const transaction of transactions) {
if (dayjs(transaction.dt) <= balanceAssertionDt) {
for (const posting of transaction.postings) {
if (posting.account === balanceAssertion.account) {
accountBalance += posting.quantity_ascost!;
}
}
}
}
balanceAssertion.isValid = balanceAssertion.quantity === accountBalance && balanceAssertion.commodity === db.metadata.reporting_commodity;
}
balanceAssertions.value = rawBalanceAssertions as ValidatedBalanceAssertion[];
balanceAssertions.value = JSON.parse(await invoke('get_validated_balance_assertions'));
}
load();

View File

@ -60,16 +60,13 @@
<script setup lang="ts">
import Clusterize from 'clusterize.js';
import dayjs from 'dayjs';
import { PencilIcon, PlusIcon } from '@heroicons/vue/24/outline';
import { invoke } from '@tauri-apps/api/core';
import { onUnmounted, ref, watch } from 'vue';
import { Transaction, db } from '../db.ts';
import { Transaction } from '../db.ts';
import { pp, ppWithCommodity } from '../display.ts';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import { renderComponent } from '../webutil.ts';
const commodityDetail = ref(false);
@ -78,14 +75,9 @@
let clusterize: Clusterize | null = null;
async function load() {
const session = await db.load();
const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session);
transactions.value = JSON.parse(await invoke('get_all_transactions_except_earnings_to_equity'));
transactions.value = reportingWorkflow.getTransactionsAtStage(ReportingStage.FINAL_STAGE);
// Display transactions in reverse chronological order
// We must sort here because they are returned by reportingWorkflow in order of ReportingStage
// Display transactions in reverse chronological order - they are returned in arbitrary order
transactions.value.sort((a, b) => (b.dt.localeCompare(a.dt)) || ((b.id ?? 0) - (a.id ?? 0)));
}

View File

@ -1,6 +1,6 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222025 Lee Yingtong Li (RunasSudo)
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
@ -38,15 +38,13 @@
</template>
<script setup lang="ts">
import { PlusIcon } from '@heroicons/vue/24/outline';
import { PlusIcon } from '@heroicons/vue/24/outline';
import { invoke } from '@tauri-apps/api/core';
import { UnlistenFn, listen } from '@tauri-apps/api/event';
import { onUnmounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Transaction, db } from '../db.ts';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import { Transaction } from '../db.ts';
import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue';
import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue';
@ -56,24 +54,20 @@
const transactions = ref([] as Transaction[]);
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
const transactionsRaw = reportingWorkflow.getTransactionsAtStage(ReportingStage.FINAL_STAGE);
// Filter only transactions affecting this account
const filteredTransactions = transactionsRaw.filter((t) => t.postings.some((p) => p.account === route.params.account));
const transactionsRaw = JSON.parse(await invoke(
'get_all_transactions_except_earnings_to_equity_for_account',
{ account: route.params.account }
)) as Transaction[];
// In order to correctly sort API transactions, we need to remember their indexes
const filteredTxnsWithIndexes = filteredTransactions.map((t, index) => [t, index] as [Transaction, number]);
const transactionsRawWithIndexes = transactionsRaw.map((t, index) => [t, index] as [Transaction, number]);
// Sort transactions in reverse chronological order
// We must sort here because they are returned by reportingWorkflow in order of ReportingStage
// Use Number.MAX_SAFE_INTEGER as ID for API transactions
filteredTxnsWithIndexes.sort(([t1, i1], [t2, i2]) => (t2.dt.localeCompare(t1.dt)) || ((t2.id ?? Number.MAX_SAFE_INTEGER) - (t1.id ?? Number.MAX_SAFE_INTEGER) || (i2 - i1)));
transactionsRawWithIndexes.sort(([t1, i1], [t2, i2]) => (t2.dt.localeCompare(t1.dt)) || ((t2.id ?? Number.MAX_SAFE_INTEGER) - (t1.id ?? Number.MAX_SAFE_INTEGER) || (i2 - i1)));
transactions.value = filteredTxnsWithIndexes.map(([t, _idx]) => t);
transactions.value = transactionsRawWithIndexes.map(([t, _idx]) => t);
}
load();

View File

@ -1,79 +0,0 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 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/>.
-->
<template>
<h1 class="page-heading mb-4">
Trial balance
</h1>
<table class="min-w-full" v-if="report">
<thead>
<tr class="border-b border-gray-300">
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Account</th>
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Cr</th>
</tr>
</thead>
<tbody>
<tr v-for="[account, quantity] in report.balances.entries()">
<td class="py-0.5 pr-1 text-gray-900"><RouterLink :to="{ name: 'transactions', params: { account: account } }" class="hover:text-blue-700 hover:underline">{{ account }}</RouterLink></td>
<td class="py-0.5 px-1 text-gray-900 text-end">
<template v-if="quantity >= 0">{{ pp(quantity) }}</template>
</td>
<td class="py-0.5 pl-1 text-gray-900 text-end">
<template v-if="quantity < 0">{{ pp(-quantity) }}</template>
</td>
</tr>
<tr>
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Total</th>
<th class="py-0.5 px-1 text-gray-900 text-end">{{ pp(total_dr!) }}</th>
<th class="py-0.5 pl-1 text-gray-900 text-end">{{ pp(-total_cr!) }}</th>
</tr>
</tbody>
</table>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { db } from '../db.ts';
import { pp } from '../display.ts';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import TrialBalanceReport from '../reports/TrialBalanceReport.ts';
const report = ref(null as TrialBalanceReport | null);
// WebKit: Iterator.reduce not supported - https://bugs.webkit.org/show_bug.cgi?id=248650
const total_dr = computed(() => report.value ?
[...report.value.balances.values()].reduce((acc, x) => x > 0 ? acc + x : acc, 0)
: 0
);
const total_cr = computed(() => report.value ?
[...report.value.balances.values()].reduce((acc, x) => x < 0 ? acc + x : acc, 0)
: 0
);
async function load() {
const session = await db.load();
const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session);
report.value = reportingWorkflow.getReportAtStage(ReportingStage.FINAL_STAGE, TrialBalanceReport) as TrialBalanceReport;
}
load();
</script>

View File

@ -1,294 +0,0 @@
/*
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222025 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 dayjs from 'dayjs';
import { asCost } from './amounts.ts';
import { DT_FORMAT, JoinedTransactionPosting, StatementLine, Transaction, db, getAccountsForKind, joinedToTransactions, totalBalances, totalBalancesAtDate } from './db.ts';
import { ExtendedDatabase } from './dbutil.ts';
import { BalanceSheetReport } from './reports/BalanceSheetReport.vue';
import { DrcrReport } from './reports/base.ts';
import TrialBalanceReport from './reports/TrialBalanceReport.ts';
import { IncomeStatementReport } from './reports/IncomeStatementReport.vue';
export enum ReportingStage {
// Load transactions from database
TransactionsFromDatabase = 100,
// Load unreconciled statement lines and other ordinary API transactions
OrdinaryAPITransactions = 200,
// Recognise accumulated surplus as equity
AccumulatedSurplusToEquity = 300,
// Interim income statement considering only DB and ordinary API transactions
InterimIncomeStatement = 400,
// Income tax estimation
//Tax = 500,
// Final income statement
//IncomeStatement = 600,
// Final balance sheet
BalanceSheet = 700,
FINAL_STAGE = BalanceSheet
}
export class ReportingWorkflow {
transactionsForStage: Map<ReportingStage, Transaction[]> = new Map();
reportsForStage: Map<ReportingStage, DrcrReport[]> = new Map();
async generate(session: ExtendedDatabase, dt?: string, dtStart?: 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, transaction_description, id, description, account, quantity, commodity, quantity_ascost, running_balance
FROM transactions_with_running_balances
WHERE DATE(dt) <= DATE($1)
ORDER BY dt, transaction_id, id`,
[dt]
);
} else {
joinedTransactionPostings = await session.select(
`SELECT transaction_id, dt, transaction_description, id, description, account, quantity, commodity, quantity_ascost, running_balance
FROM transactions_with_running_balances
ORDER BY dt, transaction_id, 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,
quantity_ascost: asCost(line.quantity, line.commodity),
},
{
id: null,
description: null,
account: unclassifiedAccount,
quantity: -line.quantity,
commodity: line.commodity,
quantity_ascost: asCost(-line.quantity, line.commodity),
}
]
));
}
this.transactionsForStage.set(ReportingStage.OrdinaryAPITransactions, transactions);
// Recompute balances
balances = applyTransactionsToBalances(balances, transactions);
this.reportsForStage.set(ReportingStage.OrdinaryAPITransactions, [new TrialBalanceReport(balances)]);
}
// --------------------------
// AccumulatedSurplusToEquity
{
// Compute balances at period start for TransactionsFromDatabase
let dayBeforePeriodStart;
if (dtStart) {
dayBeforePeriodStart = dayjs(dtStart).subtract(1, 'day');
} else {
dayBeforePeriodStart = dayjs(db.metadata.eofy_date).subtract(1, 'year');
}
const balancesAtPeriodStart = await totalBalancesAtDate(session, dayBeforePeriodStart.format('YYYY-MM-DD'));
// Add balances at period start for OrdinaryAPITransactions
for (const transaction of this.transactionsForStage.get(ReportingStage.OrdinaryAPITransactions)!) {
if (!dayjs(transaction.dt).isAfter(dayBeforePeriodStart)) {
for (const posting of transaction.postings) {
balancesAtPeriodStart.set(posting.account, (balancesAtPeriodStart.get(posting.account) ?? 0) + posting.quantity_ascost!);
}
}
}
// Get income and expense accounts
const incomeAccounts = await getAccountsForKind(session, 'drcr.income');
const expenseAccounts = await getAccountsForKind(session, 'drcr.expense');
const pandlAccounts = [...incomeAccounts, ...expenseAccounts];
pandlAccounts.sort();
// Prepare transactions
const transactions = [];
for (const account of pandlAccounts) {
if (balancesAtPeriodStart.has(account)) {
const balanceAtPeriodStart = balancesAtPeriodStart.get(account)!;
if (balanceAtPeriodStart === 0) {
continue;
}
transactions.push(new Transaction(
null,
dayBeforePeriodStart.format(DT_FORMAT),
'Accumulated surplus/deficit',
[
{
id: null,
description: null,
account: account,
quantity: -balanceAtPeriodStart,
commodity: db.metadata.reporting_commodity,
quantity_ascost: asCost(-balanceAtPeriodStart, db.metadata.reporting_commodity),
},
{
id: null,
description: null,
account: 'Accumulated surplus (deficit)',
quantity: balanceAtPeriodStart,
commodity: db.metadata.reporting_commodity,
quantity_ascost: asCost(balanceAtPeriodStart, db.metadata.reporting_commodity),
},
]
));
}
}
this.transactionsForStage.set(ReportingStage.AccumulatedSurplusToEquity, transactions);
// Recompute balances
balances = applyTransactionsToBalances(balances, transactions);
this.reportsForStage.set(ReportingStage.AccumulatedSurplusToEquity, [new TrialBalanceReport(balances)]);
}
// ---------------
// InterimIncomeStatement
let incomeStatementReport;
{
incomeStatementReport = new IncomeStatementReport();
await incomeStatementReport.generate(balances);
this.reportsForStage.set(ReportingStage.InterimIncomeStatement, [incomeStatementReport]);
}
// ------------
// BalanceSheet
{
const balanceSheetReport = new BalanceSheetReport();
await balanceSheetReport.generate(balances, incomeStatementReport);
this.reportsForStage.set(ReportingStage.BalanceSheet, [balanceSheetReport]);
}
}
getReportAtStage(stage: ReportingStage, reportType: any): DrcrReport {
// 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) {
return report;
}
// Recurse earlier stages
const stages = [...this.reportsForStage.keys()];
stages.reverse();
for (const earlierStage of stages) {
if (earlierStage >= stage) {
continue;
}
const report = this.reportsForStage.get(earlierStage)!.find((r) => r instanceof reportType);
if (report) {
return report;
}
}
throw new Error('Report does not exist at requested stage or any earlier stage');
}
getTransactionsAtStage(stage: ReportingStage): Transaction[] {
const transactions: Transaction[] = [];
for (const [curStage, curTransactions] of this.transactionsForStage.entries()) {
if (curStage <= stage) {
transactions.push(...curTransactions);
}
}
return transactions;
}
}
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 runningBalance = openingBalance + posting.quantity_ascost!;
newBalances.set(posting.account, runningBalance);
}
}
// Sort accounts
return new Map([...newBalances.entries()].sort((a, b) => a[0].localeCompare(b[0])));
}

View File

@ -1,6 +1,6 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222025 Lee Yingtong Li (RunasSudo)
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
@ -16,52 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
export class BalanceSheetReport extends DynamicReport {
constructor() {
super('Balance sheet');
}
async generate(balances: Map<string, number>, incomeStatementReport: IncomeStatementReport) {
this.entries = [
new Section(
'Assets',
[
...await DynamicReport.entriesForKind(balances, 'drcr.asset'),
new Subtotal('Total assets', 'total_assets', true /* visible */, true /* bordered */)
]
),
new Spacer(),
new Section(
'Liabilities',
[
...await DynamicReport.entriesForKind(balances, 'drcr.liability', true),
new Subtotal('Total liabilities', 'total_liabilities', true /* visible */, true /* bordered */)
]
),
new Spacer(),
new Section(
'Equity',
[
...await DynamicReport.entriesForKind(balances, 'drcr.equity', true),
new Entry('Current year surplus (deficit)', (incomeStatementReport.byId('net_surplus') as Computed).quantity, null /* id */, true /* visible */, false /* autoHide */, '/income-statement'),
new Entry('Accumulated surplus (deficit)', -(balances.get('Accumulated surplus (deficit)') ?? 0), null /* id */, true /* visible */, false /* autoHide */, '/transactions/Accumulated surplus (deficit)'),
new Subtotal('Total equity', 'total_equity', true /* visible */, true /* bordered */)
]
)
];
this.calculate();
}
}
</script>
<!-- Report display -->
<template>
<ComparativeDynamicReportComponent :reports="reports" :labels="reportLabels">
<DynamicReportComponent :report="report">
<div class="my-2 py-2 flex">
<div class="grow flex gap-x-2 items-baseline">
<span class="whitespace-nowrap">As at</span>
@ -88,55 +44,47 @@
</div>
</div>
</div>
</ComparativeDynamicReportComponent>
</DynamicReportComponent>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { invoke } from '@tauri-apps/api/core';
import { computed, ref, watch } from 'vue';
import { ExclamationCircleIcon } from '@heroicons/vue/20/solid';
import { Computed, DynamicReport, Entry, Section, Spacer, Subtotal } from './base.ts';
import { IncomeStatementReport} from './IncomeStatementReport.vue';
import { DynamicReport, LiteralRow, reportEntryById } from './base.ts';
import { db } from '../db.ts';
import { ExtendedDatabase } from '../dbutil.ts';
import ComparativeDynamicReportComponent from '../components/ComparativeDynamicReportComponent.vue';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import DynamicReportComponent from '../components/DynamicReportComponent.vue';
const reports = ref([] as BalanceSheetReport[]);
const reportLabels = ref([] as string[]);
const report = ref(null as DynamicReport | null);
const dt = ref(null as string | null);
const comparePeriods = ref(1);
const compareUnit = ref('years');
async function load() {
const session = await db.load();
await db.load();
dt.value = db.metadata.eofy_date;
await updateReport(session);
await updateReport();
// Update report when dates etc. changed
// We initialise the watcher here only after dt and dtStart are initialised above
watch([dt, comparePeriods, compareUnit], async () => {
const session = await db.load();
await updateReport(session);
});
// We initialise the watcher here only after dt is initialised above
watch([dt, comparePeriods, compareUnit], updateReport);
}
load();
async function updateReport(session: ExtendedDatabase) {
const newReportPromises = [];
const newReportLabels = [];
async function updateReport() {
const reportDates = [];
for (let i = 0; i < comparePeriods.value; i++) {
let thisReportDt, thisReportLabel;
let thisReportDt;
// Get period end date
if (compareUnit.value === 'years') {
thisReportDt = dayjs(dt.value!).subtract(i, 'year');
thisReportLabel = dayjs(dt.value!).subtract(i, 'year').format('YYYY');
} else if (compareUnit.value === 'months') {
if (dayjs(dt.value!).add(1, 'day').isSame(dayjs(dt.value!).set('date', 1).add(1, 'month'))) {
// If dt is the end of a calendar month, then fix each prior dt to be the end of the calendar month
@ -144,47 +92,30 @@
} else {
thisReportDt = dayjs(dt.value!).subtract(i, 'month');
}
thisReportLabel = dayjs(dt.value!).subtract(i, 'month').format('YYYY-MM');
} else {
throw new Error('Unexpected compareUnit');
}
// Get start of financial year date
let sofyDayjs = dayjs(db.metadata.eofy_date).subtract(1, 'year').add(1, 'day');
let thisReportDtStart = thisReportDt.set('date', sofyDayjs.get('date')).set('month', sofyDayjs.get('month'));
if (thisReportDtStart.isAfter(thisReportDt)) {
thisReportDtStart = thisReportDtStart.subtract(1, 'year');
}
console.log([thisReportDt, thisReportDtStart]);
// Generate reports asynchronously
newReportPromises.push((async () => {
const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session, thisReportDt.format('YYYY-MM-DD'), thisReportDtStart.format('YYYY-MM-DD'));
return reportingWorkflow.getReportAtStage(ReportingStage.FINAL_STAGE, BalanceSheetReport) as BalanceSheetReport;
})());
if (comparePeriods.value === 1) {
// If only 1 report, the heading is simply "$"
newReportLabels.push(db.metadata.reporting_commodity);
} else {
newReportLabels.push(thisReportLabel);
}
reportDates.push(thisReportDt.format('YYYY-MM-DD'));
}
reports.value = await Promise.all(newReportPromises);
reportLabels.value = newReportLabels;
report.value = JSON.parse(await invoke('get_balance_sheet', { dates: reportDates }));
}
const doesBalance = computed(function() {
if (report.value === null) {
return true;
}
const totalAssets = (reportEntryById(report.value, 'total_assets') as { LiteralRow: LiteralRow }).LiteralRow.quantity;
const totalLiabilities = (reportEntryById(report.value, 'total_liabilities') as { LiteralRow: LiteralRow }).LiteralRow.quantity;
const totalEquity = (reportEntryById(report.value, 'total_equity') as { LiteralRow: LiteralRow }).LiteralRow.quantity;
let doesBalance = true;
for (const report of reports.value) {
const totalAssets = (report.byId('total_assets') as Computed).quantity;
const totalLiabilities = (report.byId('total_liabilities') as Computed).quantity;
const totalEquity = (report.byId('total_equity') as Computed).quantity;
if (totalAssets !== totalLiabilities + totalEquity) {
for (let column = 0; column < report.value.columns.length; column++) {
if (totalAssets[column] !== totalLiabilities[column] + totalEquity[column]) {
doesBalance = false;
break;
}
}
return doesBalance;

View File

@ -1,6 +1,6 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222025 Lee Yingtong Li (RunasSudo)
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
@ -16,48 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
export class IncomeStatementReport extends DynamicReport {
constructor() {
super('Income statement');
}
async generate(balances: Map<string, number>) {
const report = this;
this.entries = [
new Section(
'Income',
[
...await DynamicReport.entriesForKind(balances, 'drcr.income', true),
new Subtotal('Total income', 'total_income', true /* visible */, true /* bordered */)
]
),
new Spacer(),
new Section(
'Expenses',
[
...await DynamicReport.entriesForKind(balances, 'drcr.expense'),
new Subtotal('Total expenses', 'total_expenses', true /* visible */, true /* bordered */)
]
),
new Spacer(),
new Computed(
'Net surplus (deficit)',
() => (report.byId('total_income') as Subtotal).quantity - (report.byId('total_expenses') as Subtotal).quantity,
'net_surplus',
true /* visible */, false /* autoHide */, null /* link */, true /* heading */, true /* bordered */
)
];
this.calculate();
}
}
</script>
<!-- Report display -->
<template>
<ComparativeDynamicReportComponent :reports="reports" :labels="reportLabels">
<DynamicReportComponent :report="report">
<div class="my-2 py-2 flex">
<div class="grow flex gap-x-2 items-baseline">
<input type="date" class="bordered-field" v-model.lazy="dtStart">
@ -75,21 +35,19 @@
</div>
</div>
</div>
</ComparativeDynamicReportComponent>
</DynamicReportComponent>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import dayjs from 'dayjs';
import { invoke } from '@tauri-apps/api/core';
import { ref, watch } from 'vue';
import { Computed, DynamicReport, Section, Spacer, Subtotal } from './base.ts';
import { DynamicReport } from './base.ts';
import { db } from '../db.ts';
import { ExtendedDatabase } from '../dbutil.ts';
import ComparativeDynamicReportComponent from '../components/ComparativeDynamicReportComponent.vue';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import DynamicReportComponent from '../components/DynamicReportComponent.vue';
const reports = ref([] as IncomeStatementReport[]);
const reportLabels = ref([] as string[]);
const report = ref(null as DynamicReport | null);
const dt = ref(null as string | null);
const dtStart = ref(null as string | null);
@ -98,32 +56,27 @@
const compareUnit = ref('years');
async function load() {
const session = await db.load();
await db.load();
dt.value = db.metadata.eofy_date;
dtStart.value = dayjs(db.metadata.eofy_date).subtract(1, 'year').add(1, 'day').format('YYYY-MM-DD');
await updateReport(session);
await updateReport();
// Update report when dates etc. changed
// We initialise the watcher here only after dt and dtStart are initialised above
watch([dt, dtStart, comparePeriods, compareUnit], async () => {
const session = await db.load();
await updateReport(session);
});
watch([dt, dtStart, comparePeriods, compareUnit], updateReport);
}
async function updateReport(session: ExtendedDatabase) {
const newReportPromises = [];
const newReportLabels = [];
async function updateReport() {
const reportDates = [];
for (let i = 0; i < comparePeriods.value; i++) {
let thisReportDt, thisReportDtStart, thisReportLabel;
let thisReportDt, thisReportDtStart;
// Get period start and end dates
if (compareUnit.value === 'years') {
thisReportDt = dayjs(dt.value!).subtract(i, 'year');
thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'year');
thisReportLabel = dayjs(dt.value!).subtract(i, 'year').format('YYYY');
} else if (compareUnit.value === 'months') {
if (dayjs(dt.value!).add(1, 'day').isSame(dayjs(dt.value!).set('date', 1).add(1, 'month'))) {
// If dt is the end of a calendar month, then fix each prior dt to be the end of the calendar month
@ -133,28 +86,14 @@
thisReportDt = dayjs(dt.value!).subtract(i, 'month');
thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'month');
}
thisReportLabel = dayjs(dt.value!).subtract(i, 'month').format('YYYY-MM');
} else {
throw new Error('Unexpected compareUnit');
}
// Generate reports asynchronously
newReportPromises.push((async () => {
const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session, thisReportDt.format('YYYY-MM-DD'), thisReportDtStart.format('YYYY-MM-DD'));
return reportingWorkflow.getReportAtStage(ReportingStage.InterimIncomeStatement, IncomeStatementReport) as IncomeStatementReport;
})());
if (comparePeriods.value === 1) {
// If only 1 report, the heading is simply "$"
newReportLabels.push(db.metadata.reporting_commodity);
} else {
newReportLabels.push(thisReportLabel);
}
reportDates.push([thisReportDtStart.format('YYYY-MM-DD'), thisReportDt.format('YYYY-MM-DD')]);
}
reports.value = await Promise.all(newReportPromises);
reportLabels.value = newReportLabels;
report.value = JSON.parse(await invoke('get_income_statement', { dates: reportDates }));
}
load();

View File

@ -0,0 +1,60 @@
<!--
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/>.
-->
<template>
<DynamicReportComponent :report="report">
<div class="my-2 py-2 flex">
<div class="grow flex gap-x-2 items-baseline">
<span class="whitespace-nowrap">As at</span>
<input type="date" class="bordered-field" v-model.lazy="dt">
</div>
</div>
</DynamicReportComponent>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { invoke } from '@tauri-apps/api/core';
import { ref, watch } from 'vue';
import { DynamicReport } from './base.ts';
import { db } from '../db.ts';
import DynamicReportComponent from '../components/DynamicReportComponent.vue';
const report = ref(null as DynamicReport | null);
const dt = ref(null as string | null);
async function load() {
await db.load();
dt.value = db.metadata.eofy_date;
await updateReport();
// Update report when dates etc. changed
// We initialise the watcher here only after dt is initialised above
watch([dt], updateReport);
}
load();
async function updateReport() {
const reportDate = dayjs(dt.value!).format('YYYY-MM-DD');
report.value = JSON.parse(await invoke('get_trial_balance', { date: reportDate }));
}
</script>

View File

@ -1,6 +1,6 @@
/*
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 Lee Yingtong Li (RunasSudo)
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
@ -16,168 +16,50 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { db, getAccountsForKind } from '../db.ts';
export interface DrcrReport {
// Cannot be a class as these are directly deserialised from JSON
export interface DynamicReport {
title: string;
columns: string[];
entries: DynamicReportEntry[];
}
export interface DynamicReportNode {
// serde_json serialises an enum like this
export type DynamicReportEntry = { Section: Section } | { LiteralRow: LiteralRow } | 'Spacer';
export interface Section {
text: string;
id: string | null;
calculate(parent: DynamicReport | DynamicReportNode): void;
visible: boolean;
auto_hide: boolean;
entries: DynamicReportEntry[];
}
export class DynamicReport implements DrcrReport {
constructor(
public title: string,
public entries: DynamicReportNode[] = [],
) {}
byId(id: string): DynamicReportNode | null {
// Get the DynamicReportNode with the given ID
for (const entry of this.entries) {
if (entry.id === id) {
export interface LiteralRow {
text: string;
quantity: number[];
id: string;
visible: boolean;
auto_hide: boolean;
link: string | null;
heading: boolean;
bordered: boolean;
}
export interface Spacer {
}
export function reportEntryById(report: DynamicReport | Section, id: string): DynamicReportEntry | null {
for (const entry of report.entries) {
if ((entry as { Section: Section }).Section) {
const result = reportEntryById((entry as { Section: Section }).Section, id);
if (result !== null) {
return result;
}
} else if ((entry as { LiteralRow: LiteralRow }).LiteralRow) {
if ((entry as { LiteralRow: LiteralRow }).LiteralRow.id === id) {
return entry;
}
if (entry instanceof Section) {
const result = entry.byId(id);
if (result) {
return result;
}
}
}
return null;
}
calculate() {
// Compute all subtotals
for (const entry of this.entries) {
entry.calculate(this);
}
}
static async entriesForKind(balances: Map<string, number>, kind: string, negate = false) {
// Get accounts associated with this kind
const accountsForKind = await getAccountsForKind(await db.load(), kind);
// Return one entry for each such account
const entries = [];
for (const account of accountsForKind) {
if (balances.has(account)) {
const quantity = balances.get(account)!;
if (quantity === 0) {
continue;
}
entries.push(new Entry(
account,
negate ? -quantity : quantity,
null /* id */,
true /* visible */,
false /* autoHide */,
'/transactions/' + account
));
}
}
return entries;
}
}
export class Entry implements DynamicReportNode {
constructor(
public text: string,
public quantity: number,
public id: string | null = null,
public visible = true,
public autoHide = false,
public link: string | null = null,
public heading = false,
public bordered = false,
) {}
calculate(_parent: DynamicReport | DynamicReportNode) {}
}
export class Computed extends Entry {
constructor(
public text: string,
public calc: Function,
public id: string | null = null,
public visible = true,
public autoHide = false,
public link: string | null = null,
public heading = false,
public bordered = false,
) {
super(text, null!, id, visible, autoHide, link, heading, bordered);
}
calculate(_parent: DynamicReport | DynamicReportNode) {
// Calculate the value of this entry
this.quantity = this.calc();
}
}
export class Section implements DynamicReportNode {
constructor(
public title: string | null,
public entries: DynamicReportNode[] = [],
public id: string | null = null,
public visible = true,
public autoHide = false,
) {}
calculate(_parent: DynamicReport | DynamicReportNode) {
for (const entry of this.entries) {
entry.calculate(this);
}
}
byId(id: string): DynamicReportNode | null {
// Get the DynamicReportNode with the given ID
for (const entry of this.entries) {
if (entry.id === id) {
return entry;
}
if (entry instanceof Section) {
const result = entry.byId(id);
if (result) {
return result;
}
}
}
return null;
}
}
export class Spacer implements DynamicReportNode {
id = null;
calculate(_parent: DynamicReport | DynamicReportNode) {}
}
export class Subtotal extends Entry {
constructor(
public text: string,
public id: string | null = null,
public visible = true,
public bordered = false,
public floor = 0,
) {
super(text, null!, id, visible, false /* autoHide */, null /* link */, true /* heading */, bordered);
}
calculate(parent: DynamicReport | DynamicReportNode) {
// Calculate total amount
if (!(parent instanceof Section)) {
throw new Error('Attempt to calculate Subtotal not in Section');
}
this.quantity = 0;
for (const entry of parent.entries) {
if (entry instanceof Entry && entry !== this) {
this.quantity += entry.quantity;
}
}
}
return null;
}