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 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 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 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 the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { DrcrReport } from './base.ts'; pub mod assertions;
pub mod statements;
export default class TrialBalanceReport implements DrcrReport { pub mod transaction;
constructor(
public balances: Map<string, number>
) {}
}

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

View File

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

View File

@ -1,21 +1,22 @@
/* /*
DrCr: Web-based double-entry bookkeeping framework 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 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 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 the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
mod libdrcr_bridge;
mod sql; mod sql;
use tauri::{AppHandle, Builder, Manager, State}; use tauri::{AppHandle, Builder, Manager, State};
@ -32,20 +33,26 @@ struct AppState {
// Filename state // Filename state
#[tauri::command] #[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; let state = state.lock().await;
Ok(state.db_filename.clone()) Ok(state.db_filename.clone())
} }
#[tauri::command] #[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; let mut state = state.lock().await;
state.db_filename = filename.clone(); state.db_filename = filename.clone();
// Persist in store // Persist in store
let store = app.store("store.json").expect("Error opening store"); let store = app.store("store.json").expect("Error opening store");
store.set("db_filename", filename); store.set("db_filename", filename);
Ok(()) Ok(())
} }
@ -65,15 +72,15 @@ pub fn run() {
} else { } else {
None None
} }
}, }
_ => panic!("Unexpected db_filename in store") _ => panic!("Unexpected db_filename in store"),
}; };
app.manage(Mutex::new(AppState { app.manage(Mutex::new(AppState {
db_filename: db_filename, db_filename: db_filename,
sql_transactions: Vec::new(), sql_transactions: Vec::new(),
})); }));
Ok(()) Ok(())
}) })
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@ -81,8 +88,19 @@ pub fn run() {
.plugin(tauri_plugin_sql::Builder::new().build()) .plugin(tauri_plugin_sql::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_open_filename, set_open_filename, get_open_filename,
sql::sql_transaction_begin, sql::sql_transaction_execute, sql::sql_transaction_select, sql::sql_transaction_rollback, sql::sql_transaction_commit 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!()) .run(tauri::generate_context!())
.expect("Error while running tauri application"); .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 { getCurrentWindow } from '@tauri-apps/api/window';
import { defineProps } from 'vue';
import { DT_FORMAT, db, deserialiseAmount } from '../db.ts'; import { DT_FORMAT, db, deserialiseAmount } from '../db.ts';
import ComboBoxAccounts from './ComboBoxAccounts.vue'; import ComboBoxAccounts from './ComboBoxAccounts.vue';

View File

@ -1,6 +1,6 @@
<!-- <!--
DrCr: Web-based double-entry bookkeeping framework 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 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 it under the terms of the GNU Affero General Public License as published by
@ -44,7 +44,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronUpDownIcon } from '@heroicons/vue/24/outline'; 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 { values, inputClass } = defineProps<{ values: string[], inputClass?: string }>();
const inputField = useTemplateRef('inputField'); const inputField = useTemplateRef('inputField');

View File

@ -21,7 +21,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineProps, ref } from 'vue'; import { ref } from 'vue';
import { db } from '../db.ts'; import { db } from '../db.ts';
import ComboBox from './ComboBox.vue'; 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 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 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 it under the terms of the GNU Affero General Public License as published by
@ -43,7 +43,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/24/outline'; 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]] 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 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 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 it under the terms of the GNU Affero General Public License as published by
@ -28,22 +28,19 @@
<thead> <thead>
<tr class="border-b border-gray-300"> <tr class="border-b border-gray-300">
<th></th> <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> </tr>
</thead> </thead>
<tbody> <tbody>
<DynamicReportEntry :entry="entry" v-for="entry of report.entries" /> <DynamicReportEntryComponent :entry="entry" v-for="entry of report.entries" />
</tbody> </tbody>
</table> </table>
</template> </template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineProps } from 'vue';
import { db } from '../db.ts';
import { DynamicReport } from '../reports/base.ts'; import { DynamicReport } from '../reports/base.ts';
import DynamicReportEntry from './DynamicReportEntry.vue'; import DynamicReportEntryComponent from './DynamicReportEntryComponent.vue';
const { report } = defineProps<{ report: DynamicReport | null }>(); const { report } = defineProps<{ report: DynamicReport | null }>();
</script> </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 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 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 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', name: 'statement-lines', component: () => import('./pages/StatementLinesView.vue') },
{ path: '/statement-lines/import', name: 'import-statement', component: () => import('./pages/ImportStatementView.vue') }, { path: '/statement-lines/import', name: 'import-statement', component: () => import('./pages/ImportStatementView.vue') },
{ path: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.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({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),

View File

@ -1,6 +1,6 @@
<!-- <!--
DrCr: Web-based double-entry bookkeeping framework 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 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 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 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 pr-1 text-gray-900">{{ assertion.quantity >= 0 ? 'Dr' : 'Cr' }}</td>
<td class="py-0.5 px-1 text-gray-900"> <td class="py-0.5 px-1 text-gray-900">
<CheckIcon class="w-4 h-4" 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.isValid" /> <XMarkIcon class="w-4 h-4 text-red-500" v-if="!assertion.is_valid" />
</td> </td>
<td class="py-0.5 pl-1 text-gray-900 text-end"> <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);"> <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"> <script setup lang="ts">
import dayjs from 'dayjs'; 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 { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { PlusIcon } from '@heroicons/vue/16/solid'; 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[]); const balanceAssertions = ref([] as ValidatedBalanceAssertion[]);
@ -81,42 +79,11 @@
account: string, account: string,
quantity: number, quantity: number,
commodity: string, commodity: string,
isValid: boolean, is_valid: boolean,
} }
async function load() { async function load() {
const session = await db.load(); balanceAssertions.value = JSON.parse(await invoke('get_validated_balance_assertions'));
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[];
} }
load(); load();

View File

@ -60,16 +60,13 @@
<script setup lang="ts"> <script setup lang="ts">
import Clusterize from 'clusterize.js'; import Clusterize from 'clusterize.js';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { PencilIcon, PlusIcon } from '@heroicons/vue/24/outline'; import { PencilIcon, PlusIcon } from '@heroicons/vue/24/outline';
import { invoke } from '@tauri-apps/api/core';
import { onUnmounted, ref, watch } from 'vue'; import { onUnmounted, ref, watch } from 'vue';
import { Transaction, db } from '../db.ts'; import { Transaction } from '../db.ts';
import { pp, ppWithCommodity } from '../display.ts'; import { pp, ppWithCommodity } from '../display.ts';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import { renderComponent } from '../webutil.ts'; import { renderComponent } from '../webutil.ts';
const commodityDetail = ref(false); const commodityDetail = ref(false);
@ -78,14 +75,9 @@
let clusterize: Clusterize | null = null; let clusterize: Clusterize | null = null;
async function load() { async function load() {
const session = await db.load(); transactions.value = JSON.parse(await invoke('get_all_transactions_except_earnings_to_equity'));
const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session);
transactions.value = reportingWorkflow.getTransactionsAtStage(ReportingStage.FINAL_STAGE); // Display transactions in reverse chronological order - they are returned in arbitrary order
// Display transactions in reverse chronological order
// We must sort here because they are returned by reportingWorkflow in order of ReportingStage
transactions.value.sort((a, b) => (b.dt.localeCompare(a.dt)) || ((b.id ?? 0) - (a.id ?? 0))); 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 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 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 it under the terms of the GNU Affero General Public License as published by
@ -38,15 +38,13 @@
</template> </template>
<script setup lang="ts"> <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 { UnlistenFn, listen } from '@tauri-apps/api/event';
import { onUnmounted, ref } from 'vue'; import { onUnmounted, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { Transaction, db } from '../db.ts'; import { Transaction } from '../db.ts';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue'; import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue';
import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue'; import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue';
@ -56,24 +54,20 @@
const transactions = ref([] as Transaction[]); const transactions = ref([] as Transaction[]);
async function load() { async function load() {
const session = await db.load(); const transactionsRaw = JSON.parse(await invoke(
const reportingWorkflow = new ReportingWorkflow(); 'get_all_transactions_except_earnings_to_equity_for_account',
await reportingWorkflow.generate(session); // This also ensures running balances are up to date { account: route.params.account }
)) as Transaction[];
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));
// In order to correctly sort API transactions, we need to remember their indexes // 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 // Sort transactions in reverse chronological order
// We must sort here because they are returned by reportingWorkflow in order of ReportingStage // We must sort here because they are returned by reportingWorkflow in order of ReportingStage
// Use Number.MAX_SAFE_INTEGER as ID for API transactions // 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(); 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 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 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 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/>. 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> <template>
<ComparativeDynamicReportComponent :reports="reports" :labels="reportLabels"> <DynamicReportComponent :report="report">
<div class="my-2 py-2 flex"> <div class="my-2 py-2 flex">
<div class="grow flex gap-x-2 items-baseline"> <div class="grow flex gap-x-2 items-baseline">
<span class="whitespace-nowrap">As at</span> <span class="whitespace-nowrap">As at</span>
@ -88,55 +44,47 @@
</div> </div>
</div> </div>
</div> </div>
</ComparativeDynamicReportComponent> </DynamicReportComponent>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs'; 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 { ExclamationCircleIcon } from '@heroicons/vue/20/solid';
import { Computed, DynamicReport, Entry, Section, Spacer, Subtotal } from './base.ts'; import { DynamicReport, LiteralRow, reportEntryById } from './base.ts';
import { IncomeStatementReport} from './IncomeStatementReport.vue';
import { db } from '../db.ts'; import { db } from '../db.ts';
import { ExtendedDatabase } from '../dbutil.ts'; import DynamicReportComponent from '../components/DynamicReportComponent.vue';
import ComparativeDynamicReportComponent from '../components/ComparativeDynamicReportComponent.vue';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
const reports = ref([] as BalanceSheetReport[]); const report = ref(null as DynamicReport | null);
const reportLabels = ref([] as string[]);
const dt = ref(null as string | null); const dt = ref(null as string | null);
const comparePeriods = ref(1); const comparePeriods = ref(1);
const compareUnit = ref('years'); const compareUnit = ref('years');
async function load() { async function load() {
const session = await db.load(); await db.load();
dt.value = db.metadata.eofy_date; dt.value = db.metadata.eofy_date;
await updateReport(session); await updateReport();
// Update report when dates etc. changed // Update report when dates etc. changed
// We initialise the watcher here only after dt and dtStart are initialised above // We initialise the watcher here only after dt is initialised above
watch([dt, comparePeriods, compareUnit], async () => { watch([dt, comparePeriods, compareUnit], updateReport);
const session = await db.load();
await updateReport(session);
});
} }
load(); load();
async function updateReport(session: ExtendedDatabase) { async function updateReport() {
const newReportPromises = []; const reportDates = [];
const newReportLabels = [];
for (let i = 0; i < comparePeriods.value; i++) { for (let i = 0; i < comparePeriods.value; i++) {
let thisReportDt, thisReportLabel; let thisReportDt;
// Get period end date // Get period end date
if (compareUnit.value === 'years') { if (compareUnit.value === 'years') {
thisReportDt = dayjs(dt.value!).subtract(i, 'year'); thisReportDt = dayjs(dt.value!).subtract(i, 'year');
thisReportLabel = dayjs(dt.value!).subtract(i, 'year').format('YYYY');
} else if (compareUnit.value === 'months') { } else if (compareUnit.value === 'months') {
if (dayjs(dt.value!).add(1, 'day').isSame(dayjs(dt.value!).set('date', 1).add(1, 'month'))) { 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 // 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 { } else {
thisReportDt = dayjs(dt.value!).subtract(i, 'month'); thisReportDt = dayjs(dt.value!).subtract(i, 'month');
} }
thisReportLabel = dayjs(dt.value!).subtract(i, 'month').format('YYYY-MM');
} else { } else {
throw new Error('Unexpected compareUnit'); throw new Error('Unexpected compareUnit');
} }
// Get start of financial year date reportDates.push(thisReportDt.format('YYYY-MM-DD'));
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);
}
} }
reports.value = await Promise.all(newReportPromises); report.value = JSON.parse(await invoke('get_balance_sheet', { dates: reportDates }));
reportLabels.value = newReportLabels;
} }
const doesBalance = computed(function() { 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; let doesBalance = true;
for (const report of reports.value) { for (let column = 0; column < report.value.columns.length; column++) {
const totalAssets = (report.byId('total_assets') as Computed).quantity; if (totalAssets[column] !== totalLiabilities[column] + totalEquity[column]) {
const totalLiabilities = (report.byId('total_liabilities') as Computed).quantity;
const totalEquity = (report.byId('total_equity') as Computed).quantity;
if (totalAssets !== totalLiabilities + totalEquity) {
doesBalance = false; doesBalance = false;
break;
} }
} }
return doesBalance; return doesBalance;

View File

@ -1,6 +1,6 @@
<!-- <!--
DrCr: Web-based double-entry bookkeeping framework 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 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 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/>. 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> <template>
<ComparativeDynamicReportComponent :reports="reports" :labels="reportLabels"> <DynamicReportComponent :report="report">
<div class="my-2 py-2 flex"> <div class="my-2 py-2 flex">
<div class="grow flex gap-x-2 items-baseline"> <div class="grow flex gap-x-2 items-baseline">
<input type="date" class="bordered-field" v-model.lazy="dtStart"> <input type="date" class="bordered-field" v-model.lazy="dtStart">
@ -75,21 +35,19 @@
</div> </div>
</div> </div>
</div> </div>
</ComparativeDynamicReportComponent> </DynamicReportComponent>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue';
import dayjs from 'dayjs'; 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 { db } from '../db.ts';
import { ExtendedDatabase } from '../dbutil.ts'; import DynamicReportComponent from '../components/DynamicReportComponent.vue';
import ComparativeDynamicReportComponent from '../components/ComparativeDynamicReportComponent.vue';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
const reports = ref([] as IncomeStatementReport[]); const report = ref(null as DynamicReport | null);
const reportLabels = ref([] as string[]);
const dt = ref(null as string | null); const dt = ref(null as string | null);
const dtStart = ref(null as string | null); const dtStart = ref(null as string | null);
@ -98,32 +56,27 @@
const compareUnit = ref('years'); const compareUnit = ref('years');
async function load() { async function load() {
const session = await db.load(); await db.load();
dt.value = db.metadata.eofy_date; dt.value = db.metadata.eofy_date;
dtStart.value = dayjs(db.metadata.eofy_date).subtract(1, 'year').add(1, 'day').format('YYYY-MM-DD'); 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 // Update report when dates etc. changed
// We initialise the watcher here only after dt and dtStart are initialised above // We initialise the watcher here only after dt and dtStart are initialised above
watch([dt, dtStart, comparePeriods, compareUnit], async () => { watch([dt, dtStart, comparePeriods, compareUnit], updateReport);
const session = await db.load();
await updateReport(session);
});
} }
async function updateReport(session: ExtendedDatabase) { async function updateReport() {
const newReportPromises = []; const reportDates = [];
const newReportLabels = [];
for (let i = 0; i < comparePeriods.value; i++) { for (let i = 0; i < comparePeriods.value; i++) {
let thisReportDt, thisReportDtStart, thisReportLabel; let thisReportDt, thisReportDtStart;
// Get period start and end dates // Get period start and end dates
if (compareUnit.value === 'years') { if (compareUnit.value === 'years') {
thisReportDt = dayjs(dt.value!).subtract(i, 'year'); thisReportDt = dayjs(dt.value!).subtract(i, 'year');
thisReportDtStart = dayjs(dtStart.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') { } else if (compareUnit.value === 'months') {
if (dayjs(dt.value!).add(1, 'day').isSame(dayjs(dt.value!).set('date', 1).add(1, 'month'))) { 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 // 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'); thisReportDt = dayjs(dt.value!).subtract(i, 'month');
thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'month'); thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'month');
} }
thisReportLabel = dayjs(dt.value!).subtract(i, 'month').format('YYYY-MM');
} else { } else {
throw new Error('Unexpected compareUnit'); throw new Error('Unexpected compareUnit');
} }
// Generate reports asynchronously reportDates.push([thisReportDtStart.format('YYYY-MM-DD'), thisReportDt.format('YYYY-MM-DD')]);
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);
}
} }
reports.value = await Promise.all(newReportPromises); report.value = JSON.parse(await invoke('get_income_statement', { dates: reportDates }));
reportLabels.value = newReportLabels;
} }
load(); 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 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 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 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/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { db, getAccountsForKind } from '../db.ts'; // Cannot be a class as these are directly deserialised from JSON
export interface DynamicReport {
export interface DrcrReport { 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; id: string | null;
calculate(parent: DynamicReport | DynamicReportNode): void; visible: boolean;
auto_hide: boolean;
entries: DynamicReportEntry[];
} }
export class DynamicReport implements DrcrReport { export interface LiteralRow {
constructor( text: string;
public title: string, quantity: number[];
public entries: DynamicReportNode[] = [], id: string;
) {} visible: boolean;
auto_hide: boolean;
byId(id: string): DynamicReportNode | null { link: string | null;
// Get the DynamicReportNode with the given ID heading: boolean;
for (const entry of this.entries) { bordered: boolean;
if (entry.id === id) { }
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; 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;
} }