diff options
| author | Joe Carstairs <me@joeac.net> | 2024-12-12 08:28:04 +0000 |
|---|---|---|
| committer | Joe Carstairs <me@joeac.net> | 2024-12-12 08:28:04 +0000 |
| commit | 54894f9de9bad4fecb2a116d59a03cdd003f84c0 (patch) | |
| tree | 6ce5517cecf23d4e93d7a381a374e1fa6c810cee /actualbudget_queries | |
| parent | 8a30bcbdf9264d235bf93da3df8e170cfde53d02 (diff) | |
Moves rust workspace to root
Diffstat (limited to 'actualbudget_queries')
24 files changed, 1166 insertions, 0 deletions
diff --git a/actualbudget_queries/Cargo.toml b/actualbudget_queries/Cargo.toml new file mode 100644 index 0000000..78fd603 --- /dev/null +++ b/actualbudget_queries/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "actualbudget_queries" +version = "0.1.0" +edition = "2021" + +[dependencies] +actualbudget_models = { path = "../actualbudget_models" } +actualbudget_schema = { path = "../actualbudget_schema" } +anyhow = { workspace = true } +diesel = { workspace = true } + +[dev-dependencies] +libsqlite3-sys = { workspace = true, features = ["bundled"] } diff --git a/actualbudget_queries/README.md b/actualbudget_queries/README.md new file mode 100644 index 0000000..69ab0e7 --- /dev/null +++ b/actualbudget_queries/README.md @@ -0,0 +1,70 @@ +# Actualbudget Queries + +This is a Rust library defining queries for querying an Actualbudget database +export. + +## Creating the test database + +To re-create the test database from scratch: + +1. Open an instance of Actualbudget +2. Start a new file +3. Add a new local account, called 'My bank account', with an initial balance of + 1200.00 +4. Add a new local account, called 'My cash account', with an initial balance of + 45.10 +5. Add a new local account, called 'Category transfers', with an initial balance + of nil +6. Move the Starting Balance transaction for both to 1 Aug 2024 +6. Go to the Budgets tab +7. Adjust the budgets for the existing categories for August 2024: + - Usual Expenses, under which: + - Food: 200 + - General: 100 + - Bills: 125 + - Bills (flexible): 25 + - Investments and Savings, under which: + - Savings: 400 +8. Add transactions for Income for 846.46 in the 'My bank account' on 21 Aug, + Sept, Oct and Nov 2024, with the 'Note' column set to 'Salary', ticking the + 'cleared' box +9. On 10 Sept 2024, add a transaction in 'My bank account' transferring 80.00 to + 'My cash account' with no Note, and tick the 'cleared' box +10. Add transactions for 200.00 to 'Tesco' in 'My bank account', categorised as + 'Food', for 8 Aug, Sept, Oct, and Nov 2024 +11. In the 'Category transfers' account, add a transaction for 23rd Oct 2024, + transferring 120.00 from 'Savings' to 'General' +12. Export the database by going to Settings > Export data +13. Download the resulting ZIP archive +14. Extract `db.sqlite` and `metadata.json` +15. Move them to + `actualbudget_queries/tests/resources/actualbudget_test_db.sqlite` and + `actualbudget_queries/tests/resources/actualbudget_test_db_metadata.json` + respectively, overwriting the files which are already there +16. Format the JSON in `actualbudget_test_db_metadata.json` to minimise diffs + +## Updating the test database + +To make a change to the test database: + +1. Make a copy of `actualbudget_test_db_metadata.json` and rename it to + `metadata.json` +2. Make a copy of `actualbudget_test_db.sqlite` and rename it to `db.sqlite` +3. Compress these files together in a `.zip` archive +4. Open an instance of Actualbudget +5. If you have a file open, close it +6. Choose 'Import file' +7. Choose to import an 'Actual' file +8. Choose the ZIP archive you just made +9. This should open the file +10. Make your edits +11. Document your edits in + [the database creation guide](#creating-the-test-database) +12. Export the database by going to Settings > Export data +13. Download the resulting ZIP archive +14. Extract the `.sqlite` database file +15. Move them to + `actualbudget_queries/tests/resources/actualbudget_test_db.sqlite` and + `actualbudget_queries/tests/resources/actualbudget_test_db_metadata.json` + respectively, overwriting the files which are already there +16. Format the JSON in `actualbudget_test_db_metadata.json` to minimise diffs diff --git a/actualbudget_queries/src/actualbudget_accounts.rs b/actualbudget_queries/src/actualbudget_accounts.rs new file mode 100644 index 0000000..d499208 --- /dev/null +++ b/actualbudget_queries/src/actualbudget_accounts.rs @@ -0,0 +1,25 @@ +use actualbudget_models::ActualbudgetAccount; +use actualbudget_schema::actualbudget_schema::accounts::{ + self as accounts_schema, + dsl::accounts as accounts_table, +}; +use anyhow::{Context, Result}; +use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; + +pub fn get_all_actualbudget_accounts(connection: &mut SqliteConnection) -> Result<Vec<ActualbudgetAccount>> { + let actualbudget_accounts = accounts_table + .select(ActualbudgetAccount::as_select()) + .load(connection) + .with_context(|| "failed to get all Actualbudget accounts")?; + Ok(actualbudget_accounts) +} + +pub fn get_category_transfers_account(connection: &mut SqliteConnection) -> Result<Option<ActualbudgetAccount>> { + let category_transfers_account = accounts_table + .filter(accounts_schema::name.eq("Category transfers")) + .select(ActualbudgetAccount::as_select()) + .first(connection) + .optional() + .with_context(|| "failed to get Category Transfers account")?; + Ok(category_transfers_account) +} diff --git a/actualbudget_queries/src/actualbudget_categories.rs b/actualbudget_queries/src/actualbudget_categories.rs new file mode 100644 index 0000000..846faf5 --- /dev/null +++ b/actualbudget_queries/src/actualbudget_categories.rs @@ -0,0 +1,7 @@ +use actualbudget_models::ActualbudgetCategory; +use actualbudget_schema::actualbudget_schema::categories::dsl::categories; +use diesel::{result::Error, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; + +pub fn get_all_actualbudget_categories(connection: &mut SqliteConnection) -> Result<Vec<ActualbudgetCategory>, Error> { + categories.select(ActualbudgetCategory::as_select()).load(connection) +} diff --git a/actualbudget_queries/src/actualbudget_payees.rs b/actualbudget_queries/src/actualbudget_payees.rs new file mode 100644 index 0000000..d5b0c5f --- /dev/null +++ b/actualbudget_queries/src/actualbudget_payees.rs @@ -0,0 +1,13 @@ +use actualbudget_models::ActualbudgetPayee; +use actualbudget_schema::actualbudget_schema::payees::dsl::payees; +use anyhow::{Result, Context}; +use diesel::{QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; + +pub fn get_all_actualbudget_payees( + connection: &mut SqliteConnection, +) -> Result<Vec<ActualbudgetPayee>> { + payees + .select(ActualbudgetPayee::as_select()) + .load(connection) + .with_context(|| "failed to get actualbudget payees") +} diff --git a/actualbudget_queries/src/actualbudget_transactions.rs b/actualbudget_queries/src/actualbudget_transactions.rs new file mode 100644 index 0000000..4dc38e8 --- /dev/null +++ b/actualbudget_queries/src/actualbudget_transactions.rs @@ -0,0 +1,23 @@ +use actualbudget_models::{ActualbudgetDate, ActualbudgetTransaction}; +use actualbudget_schema::actualbudget_schema::v_transactions::{ + self as transactions_schema, + dsl::v_transactions as transactions_table, +}; +use anyhow::{Context, Result}; +use diesel::{dsl::min, result::Error, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; + +pub fn get_all_actualbudget_transactions(connection: &mut SqliteConnection) -> Result<Vec<ActualbudgetTransaction>> { + let actualbudget_transactions = transactions_table + .select(ActualbudgetTransaction::as_select()) + .load(connection) + .with_context(|| "failed to get all Actualbudget transactions")?; + Ok(actualbudget_transactions) +} + +pub fn get_first_actualbudget_transaction_date(connection: &mut SqliteConnection) -> Result<ActualbudgetDate> { + let date_as_int = transactions_table + .select(min(transactions_schema::date)) + .first::<Option<i32>>(connection) + .map_or_else(|e| Err(e), |o| o.map_or_else(|| Err(Error::NotFound), |i| Ok(i)))?; + Ok(ActualbudgetDate::from_i32(date_as_int)) +} diff --git a/actualbudget_queries/src/actualbudget_zero_budgets.rs b/actualbudget_queries/src/actualbudget_zero_budgets.rs new file mode 100644 index 0000000..5ae4055 --- /dev/null +++ b/actualbudget_queries/src/actualbudget_zero_budgets.rs @@ -0,0 +1,20 @@ +use anyhow::{Context, Result}; +use actualbudget_models::{actualbudget_schema::zero_budgets as zero_budgets_schema, ActualbudgetDate, ActualbudgetZeroBudget}; +use actualbudget_schema::actualbudget_schema::zero_budgets::dsl::zero_budgets as zero_budgets_table; +use diesel::{dsl::min, result::Error, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; + +pub fn get_all_actualbudget_zero_budgets(connection: &mut SqliteConnection) -> Result<Vec<ActualbudgetZeroBudget>> { + let zero_budgets = zero_budgets_table + .select(ActualbudgetZeroBudget::as_select()) + .load(connection) + .with_context(|| "failed to get all Actualbudget zero budgets")?; + Ok(zero_budgets) +} + +pub fn get_first_zero_budget_date(connection: &mut SqliteConnection) -> Result<ActualbudgetDate> { + let date_as_int = zero_budgets_table + .select(min(zero_budgets_schema::month)) + .first::<Option<i32>>(connection) + .map_or_else(|e| Err(e), |o| o.map_or_else(|| Err(Error::NotFound), |i| Ok(i)))?; + Ok(ActualbudgetDate::from_i32(date_as_int)) +} diff --git a/actualbudget_queries/src/lib.rs b/actualbudget_queries/src/lib.rs new file mode 100644 index 0000000..ca3f6ea --- /dev/null +++ b/actualbudget_queries/src/lib.rs @@ -0,0 +1,5 @@ +pub mod actualbudget_accounts; +pub mod actualbudget_categories; +pub mod actualbudget_payees; +pub mod actualbudget_transactions; +pub mod actualbudget_zero_budgets; diff --git a/actualbudget_queries/tests/actualbudget_accounts.rs b/actualbudget_queries/tests/actualbudget_accounts.rs new file mode 100644 index 0000000..954cac1 --- /dev/null +++ b/actualbudget_queries/tests/actualbudget_accounts.rs @@ -0,0 +1,32 @@ +pub mod common; + +use actualbudget_queries::actualbudget_accounts::{get_all_actualbudget_accounts, get_category_transfers_account}; +use common::{account_names::ACCOUNT_NAMES, db_url::DB_URL}; +use diesel::{Connection, SqliteConnection}; + +#[test] +fn given_test_database_when_get_all_accounts_then_return_accounts() { + let connection = &mut SqliteConnection::establish(DB_URL).unwrap(); + + let accounts = get_all_actualbudget_accounts(connection); + + assert!(accounts.is_ok()); + let accounts = accounts.unwrap(); + assert_eq!(ACCOUNT_NAMES.len(), accounts.len()); + for account_name in ACCOUNT_NAMES { + assert!(accounts.iter().any(|a| a.name.as_str() == *account_name)); + } +} + +#[test] +fn given_test_database_when_get_category_transfer_account_then_return_category_transfer_account() { + let connection = &mut SqliteConnection::establish(DB_URL).unwrap(); + + let account = get_category_transfers_account(connection); + + assert!(account.is_ok()); + let account = account.unwrap(); + assert!(account.is_some()); + let account = account.unwrap(); + assert!(account.name == "Category transfers"); +} diff --git a/actualbudget_queries/tests/actualbudget_categories.rs b/actualbudget_queries/tests/actualbudget_categories.rs new file mode 100644 index 0000000..27c17c8 --- /dev/null +++ b/actualbudget_queries/tests/actualbudget_categories.rs @@ -0,0 +1,19 @@ +pub mod common; + +use actualbudget_queries::actualbudget_categories::get_all_actualbudget_categories; +use common::{category_names::CATEGORY_NAMES, db_url::DB_URL}; +use diesel::{Connection, SqliteConnection}; + +#[test] +fn given_test_database_when_get_all_categories_then_return_categories() { + let connection = &mut SqliteConnection::establish(DB_URL).unwrap(); + + let categories = get_all_actualbudget_categories(connection); + + assert!(categories.is_ok()); + let categories = categories.unwrap(); + assert_eq!(CATEGORY_NAMES.len(), categories.len()); + for &category_name in CATEGORY_NAMES { + assert!(categories.iter().any(|a| a.name.as_str() == category_name)); + } +} diff --git a/actualbudget_queries/tests/actualbudget_payees.rs b/actualbudget_queries/tests/actualbudget_payees.rs new file mode 100644 index 0000000..acf5c4e --- /dev/null +++ b/actualbudget_queries/tests/actualbudget_payees.rs @@ -0,0 +1,25 @@ +pub mod common; + +use actualbudget_queries::actualbudget_payees::get_all_actualbudget_payees; +use common::{db_url::DB_URL, get_reference_data::get_accounts, payee_matchers::get_payee_matchers}; +use diesel::{Connection, SqliteConnection}; + +#[test] +fn given_test_database_when_get_all_payees_then_return_payees() { + let connection = &mut SqliteConnection::establish(DB_URL).unwrap(); + let accounts = get_accounts(connection); + + let payees = get_all_actualbudget_payees(connection); + + assert!(payees.is_ok()); + let payees = payees.unwrap(); + let payee_matchers = get_payee_matchers(); + assert_eq!(payee_matchers.len(), payees.len()); + for payee in payees { + assert!( + payee_matchers.iter().any(|payee_matcher| payee_matcher(&payee, &accounts)), + "no match found for payee {:?}", + payee, + ); + } +} diff --git a/actualbudget_queries/tests/actualbudget_transactions.rs b/actualbudget_queries/tests/actualbudget_transactions.rs new file mode 100644 index 0000000..c9d72a9 --- /dev/null +++ b/actualbudget_queries/tests/actualbudget_transactions.rs @@ -0,0 +1,38 @@ +pub mod common; + +use actualbudget_queries::actualbudget_transactions::{get_all_actualbudget_transactions, get_first_actualbudget_transaction_date}; +use common::{db_url::DB_URL, get_reference_data::{get_accounts, get_categories}, transaction_matchers::get_transaction_matchers}; +use diesel::{Connection, SqliteConnection}; + +#[test] +fn given_test_database_when_get_all_transactions_then_return_transactions() { + let connection = &mut SqliteConnection::establish(DB_URL).unwrap(); + let accounts = get_accounts(connection); + let categories = get_categories(connection); + + let transactions = get_all_actualbudget_transactions(connection); + + assert!(transactions.is_ok()); + let transactions = transactions.unwrap(); + let transaction_matchers = get_transaction_matchers(); + assert_eq!(transaction_matchers.len(), transactions.len()); + for transaction in transactions { + let any_match = transaction_matchers.iter().any(|transaction_matcher| + transaction_matcher(&transaction, &accounts, &categories) + ); + assert!(any_match, "no match found for transaction {:?}", transaction); + } +} + +#[test] +fn given_test_database_when_get_first_transaction_date_then_return_01_aug_2024() { + let connection = &mut SqliteConnection::establish(DB_URL).unwrap(); + + let date = get_first_actualbudget_transaction_date(connection); + + assert!(date.is_ok()); + let date = date.unwrap(); + assert_eq!("01", date.day()); + assert_eq!("08", date.month()); + assert_eq!("2024", date.year()); +} diff --git a/actualbudget_queries/tests/actualbudget_zero_budgets.rs b/actualbudget_queries/tests/actualbudget_zero_budgets.rs new file mode 100644 index 0000000..7218227 --- /dev/null +++ b/actualbudget_queries/tests/actualbudget_zero_budgets.rs @@ -0,0 +1,34 @@ +pub mod common; + +use actualbudget_queries::actualbudget_zero_budgets::{get_all_actualbudget_zero_budgets, get_first_zero_budget_date}; +use common::{db_url::DB_URL, get_reference_data::get_categories, zero_budget_matchers::get_zero_budget_matchers}; +use diesel::{Connection, SqliteConnection}; + +#[test] +fn given_test_database_when_get_all_zero_budgets_then_return_zero_budgets() { + let connection = &mut SqliteConnection::establish(DB_URL).unwrap(); + let categories = get_categories(connection); + + let zero_budgets = get_all_actualbudget_zero_budgets(connection); + + assert!(zero_budgets.is_ok()); + let zero_budgets = zero_budgets.unwrap(); + let zero_budget_matchers = get_zero_budget_matchers(); + assert_eq!(zero_budget_matchers.len(), zero_budgets.len()); + for matcher in zero_budget_matchers { + assert!(zero_budgets.iter().any(|zb| matcher(zb, &categories))) + } +} + +#[test] +fn given_test_database_when_get_first_zero_budget_date_then_return_1_aug_2024() { + let connection = &mut SqliteConnection::establish(DB_URL).unwrap(); + + let date = get_first_zero_budget_date(connection); + + assert!(date.is_ok()); + let date = date.unwrap(); + assert_eq!("01", date.day()); + assert_eq!("08", date.month()); + assert_eq!("2024", date.year()); +} diff --git a/actualbudget_queries/tests/common/account_names.rs b/actualbudget_queries/tests/common/account_names.rs new file mode 100644 index 0000000..1abee4b --- /dev/null +++ b/actualbudget_queries/tests/common/account_names.rs @@ -0,0 +1,17 @@ +pub const ACCOUNT_NAMES: &[&'static str] = &[ + "My bank account", + "My cash account", + "Category transfers", +]; + +pub struct AccountNamesDict { + pub bank_account: &'static str, + pub cash_account: &'static str, + pub transfers_account: &'static str, +} + +pub const ACCOUNT_NAMES_DICT: AccountNamesDict = AccountNamesDict { + bank_account: ACCOUNT_NAMES[0], + cash_account: ACCOUNT_NAMES[1], + transfers_account: ACCOUNT_NAMES[2], +}; diff --git a/actualbudget_queries/tests/common/category_names.rs b/actualbudget_queries/tests/common/category_names.rs new file mode 100644 index 0000000..a2f7d84 --- /dev/null +++ b/actualbudget_queries/tests/common/category_names.rs @@ -0,0 +1,29 @@ +pub const CATEGORY_NAMES: &[&'static str] = &[ + "Food", + "General", + "Bills", + "Bills (Flexible)", + "Savings", + "Starting Balances", + "Income", +]; + +pub struct CategoryNamesDict { + pub food: &'static str, + pub general: &'static str, + pub bills: &'static str, + pub bills_flexible: &'static str, + pub savings: &'static str, + pub starting_balances: &'static str, + pub income: &'static str, +} + +pub const CATEGORY_NAMES_DICT: CategoryNamesDict = CategoryNamesDict { + food: CATEGORY_NAMES[0], + general: CATEGORY_NAMES[1], + bills: CATEGORY_NAMES[2], + bills_flexible: CATEGORY_NAMES[3], + savings: CATEGORY_NAMES[4], + starting_balances: CATEGORY_NAMES[5], + income: CATEGORY_NAMES[6], +}; diff --git a/actualbudget_queries/tests/common/db_url.rs b/actualbudget_queries/tests/common/db_url.rs new file mode 100644 index 0000000..ff778a8 --- /dev/null +++ b/actualbudget_queries/tests/common/db_url.rs @@ -0,0 +1 @@ +pub const DB_URL: &str = "tests/resources/actualbudget_test_db.sqlite"; diff --git a/actualbudget_queries/tests/common/get_reference_data.rs b/actualbudget_queries/tests/common/get_reference_data.rs new file mode 100644 index 0000000..0d4f19c --- /dev/null +++ b/actualbudget_queries/tests/common/get_reference_data.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; + +use actualbudget_models::{ActualbudgetAccount, ActualbudgetCategory}; +use actualbudget_queries::{actualbudget_accounts::get_all_actualbudget_accounts, actualbudget_categories::get_all_actualbudget_categories}; +use diesel::SqliteConnection; + +use super::{account_names::ACCOUNT_NAMES, category_names::CATEGORY_NAMES}; + +pub fn get_accounts(connection: &mut SqliteConnection) -> HashMap<String, ActualbudgetAccount> { + let accounts = get_all_actualbudget_accounts(connection).expect("failed to get accounts"); + let mut accounts_hash_map = HashMap::new(); + + for &account_name in ACCOUNT_NAMES { + let account = accounts + .clone() + .into_iter() + .find(|a| a.name.as_str() == account_name) + .expect(&format!("failed to get account: {}", account_name)); + accounts_hash_map.insert( + String::from(account_name), + account, + ); + } + + accounts_hash_map +} + +pub fn get_categories(connection: &mut SqliteConnection) -> HashMap<String, ActualbudgetCategory> { + let categories = get_all_actualbudget_categories(connection).expect("failed to get categories"); + let mut categories_hash_map = HashMap::new(); + + for &category_name in CATEGORY_NAMES { + let category = categories + .clone() + .into_iter() + .find(|a| a.name.as_str() == category_name) + .expect(&format!("failed to get category: {}", category_name)); + categories_hash_map.insert( + String::from(category_name), + category, + ); + } + + categories_hash_map +} diff --git a/actualbudget_queries/tests/common/mod.rs b/actualbudget_queries/tests/common/mod.rs new file mode 100644 index 0000000..b19b061 --- /dev/null +++ b/actualbudget_queries/tests/common/mod.rs @@ -0,0 +1,8 @@ +pub mod account_names; +pub mod category_names; +pub mod db_url; +pub mod get_reference_data; +pub mod payee_matchers; +pub mod payee_names; +pub mod transaction_matchers; +pub mod zero_budget_matchers; diff --git a/actualbudget_queries/tests/common/payee_matchers.rs b/actualbudget_queries/tests/common/payee_matchers.rs new file mode 100644 index 0000000..ccba204 --- /dev/null +++ b/actualbudget_queries/tests/common/payee_matchers.rs @@ -0,0 +1,124 @@ +use std::collections::HashMap; + +use actualbudget_models::{ActualbudgetAccount, ActualbudgetPayee}; +use anyhow::Context; + +use super::{account_names::ACCOUNT_NAMES_DICT, payee_names::PAYEE_NAMES_DICT}; + +pub fn get_payee_matchers( +) -> Vec<Box<dyn Fn(&ActualbudgetPayee, &HashMap<String, ActualbudgetAccount>) -> bool>> { + vec![ + Box::new(|p, a| matches_bank_account_transfer_account(p, a)), + Box::new(|p, a| matches_cash_account_transfer_account(p, a)), + Box::new(|p, a| matches_category_transfers_transfer_account(p, a)), + Box::new(|p, a| matches_starting_balances(p, a)), + Box::new(|p, a| matches_employer(p, a)), + Box::new(|p, a| matches_tesco(p, a)), + ] +} + +fn matches_bank_account_transfer_account( + payee: &ActualbudgetPayee, + accounts: &HashMap<String, ActualbudgetAccount>, +) -> bool { + let bank_account = accounts + .get(ACCOUNT_NAMES_DICT.bank_account) + .with_context(|| "failed to get bank account") + .unwrap(); + + match payee { + ActualbudgetPayee { + name, + transfer_account_id, + .. + } => name == "" + && transfer_account_id.clone().is_some_and( + |account_id| account_id == bank_account.id, + ), + } +} + +fn matches_cash_account_transfer_account( + payee: &ActualbudgetPayee, + accounts: &HashMap<String, ActualbudgetAccount>, +) -> bool { + let cash_account = accounts + .get(ACCOUNT_NAMES_DICT.cash_account) + .with_context(|| "failed to find cash account") + .unwrap(); + + match payee { + ActualbudgetPayee { + name, + transfer_account_id, + .. + } => name == "" + && transfer_account_id.clone().is_some_and( + |account_id| account_id == cash_account.id, + ), + } +} + +fn matches_category_transfers_transfer_account( + payee: &ActualbudgetPayee, + accounts: &HashMap<String, ActualbudgetAccount>, +) -> bool { + let category_transfers_account = accounts + .get(ACCOUNT_NAMES_DICT.transfers_account) + .with_context(|| "failed to find category transfers account") + .unwrap(); + + match payee { + ActualbudgetPayee { + name, + transfer_account_id, + .. + } => name == "" + && transfer_account_id.clone().is_some_and( + |account_id| account_id == category_transfers_account.id, + ), + } +} + +fn matches_starting_balances( + payee: &ActualbudgetPayee, + _accounts: &HashMap<String, ActualbudgetAccount>, +) -> bool { + match payee { + ActualbudgetPayee { + name, + transfer_account_id, + .. + } => transfer_account_id.is_none() + && name.clone() == PAYEE_NAMES_DICT.starting_balances, + } +} + +fn matches_employer( + payee: &ActualbudgetPayee, + _accounts: &HashMap<String, ActualbudgetAccount>, +) -> bool { + match payee { + ActualbudgetPayee { + name, + transfer_account_id, + .. + } => transfer_account_id.is_none() + && name == PAYEE_NAMES_DICT.employer, + } +} + + +fn matches_tesco( + payee: &ActualbudgetPayee, + _accounts: &HashMap<String, ActualbudgetAccount>, +) -> bool { + match payee { + ActualbudgetPayee { + name, + transfer_account_id, + .. + } => transfer_account_id.is_none() + && name == PAYEE_NAMES_DICT.tesco, + } +} diff --git a/actualbudget_queries/tests/common/payee_names.rs b/actualbudget_queries/tests/common/payee_names.rs new file mode 100644 index 0000000..42b6024 --- /dev/null +++ b/actualbudget_queries/tests/common/payee_names.rs @@ -0,0 +1,17 @@ +pub const PAYEE_NAMES: &[&'static str] = &[ + "Starting Balance", + "Employer Ltd", + "Tesco", +]; + +pub struct PayeeNamesDict { + pub starting_balances: &'static str, + pub employer: &'static str, + pub tesco: &'static str, +} + +pub const PAYEE_NAMES_DICT: PayeeNamesDict = PayeeNamesDict { + starting_balances: PAYEE_NAMES[0], + employer: PAYEE_NAMES[1], + tesco: PAYEE_NAMES[2], +}; diff --git a/actualbudget_queries/tests/common/transaction_matchers.rs b/actualbudget_queries/tests/common/transaction_matchers.rs new file mode 100644 index 0000000..2620da5 --- /dev/null +++ b/actualbudget_queries/tests/common/transaction_matchers.rs @@ -0,0 +1,315 @@ +use std::collections::HashMap; + +use actualbudget_models::{ActualbudgetAccount, ActualbudgetCategory, ActualbudgetTransaction}; + +use super::{account_names::ACCOUNT_NAMES_DICT, category_names::CATEGORY_NAMES_DICT}; + +pub fn get_transaction_matchers() -> Vec<Box<dyn Fn(&ActualbudgetTransaction, &HashMap<String, ActualbudgetAccount>, &HashMap<String, ActualbudgetCategory>) -> bool>> { + vec![ + Box::new(|t, a, c| matches_bank_account_initial_transaction(t, a, c)), + Box::new(|t, a, c| matches_cash_account_initial_transaction(t, a, c)), + Box::new(|t, a, c| matches_income_transaction(t, a, c, "08")), + Box::new(|t, a, c| matches_income_transaction(t, a, c, "09")), + Box::new(|t, a, c| matches_income_transaction(t, a, c, "10")), + Box::new(|t, a, c| matches_income_transaction(t, a, c, "11")), + Box::new(|t, a, c| matches_food_transaction(t, a, c, "08")), + Box::new(|t, a, c| matches_food_transaction(t, a, c, "09")), + Box::new(|t, a, c| matches_food_transaction(t, a, c, "10")), + Box::new(|t, a, c| matches_food_transaction(t, a, c, "11")), + Box::new(|t, a, c| matches_category_transfer_parent_transaction(t, a, c)), + Box::new(|t, a, c| matches_category_transfer_child_out_transaction(t, a, c)), + Box::new(|t, a, c| matches_category_transfer_child_in_transaction(t, a, c)), + Box::new(|t, a, c| matches_account_transfer_out_transaction(t, a, c)), + Box::new(|t, a, c| matches_account_transfer_in_transaction(t, a, c)), + ] +} + +fn matches_bank_account_initial_transaction( + t: &ActualbudgetTransaction, + accounts: &HashMap<String, ActualbudgetAccount>, + categories: &HashMap<String, ActualbudgetCategory>, +) -> bool { + let bank_account_id = accounts + .get(ACCOUNT_NAMES_DICT.bank_account) + .expect("failed to find bank account") + .id + .clone(); + + let starting_balances_category_id = categories + .get(CATEGORY_NAMES_DICT.starting_balances) + .expect("failed to find starting balances category") + .id + .clone(); + + match t { + ActualbudgetTransaction { + is_parent: false, + is_child: false, + parent_id: None, + account_id, + category_id: Some(category_id), + amount: 1200_00, + date, + .. + } => *account_id == bank_account_id + && *category_id == starting_balances_category_id + && date.to_iso().get(0..10) == Some("2024-08-01"), + _ => false, + } +} + +fn matches_cash_account_initial_transaction( + t: &ActualbudgetTransaction, + accounts: &HashMap<String, ActualbudgetAccount>, + categories: &HashMap<String, ActualbudgetCategory>, +) -> bool { + let bank_account_id = accounts + .get(ACCOUNT_NAMES_DICT.cash_account) + .expect("failed to find cash account") + .id + .clone(); + + let starting_balances_category_id = categories + .get(CATEGORY_NAMES_DICT.starting_balances) + .expect("failed to find starting balances category") + .id + .clone(); + + match t { + ActualbudgetTransaction { + is_parent: false, + is_child: false, + parent_id: None, + account_id, + category_id: Some(category_id), + amount: 45_10, + date, + .. + } => *account_id == bank_account_id + && *category_id == starting_balances_category_id + && date.to_iso().get(0..10) == Some("2024-08-01"), + _ => false, + } +} + +fn matches_income_transaction( + t: &ActualbudgetTransaction, + accounts: &HashMap<String, ActualbudgetAccount>, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let bank_account_id = accounts + .get(ACCOUNT_NAMES_DICT.bank_account) + .expect("failed to find bank account") + .id + .clone(); + + let income_category_id = categories + .get(CATEGORY_NAMES_DICT.income) + .expect("failed to find income category") + .id + .clone(); + + match t { + ActualbudgetTransaction { + is_parent: false, + is_child: false, + parent_id: None, + account_id, + category_id, + amount: 846_46, + date, + notes: Some(notes), + .. + } => + *account_id == bank_account_id + && *category_id == Some(income_category_id) + && date.to_iso().get(0..10) == Some(format!("2024-{}-21", month).as_str()) + && *notes == String::from("Salary"), + _ => false, + } +} + +fn matches_food_transaction( + t: &ActualbudgetTransaction, + accounts: &HashMap<String, ActualbudgetAccount>, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let bank_account_id = accounts + .get(ACCOUNT_NAMES_DICT.bank_account) + .expect("failed to find bank account") + .id + .clone(); + + let income_category_id = categories + .get(CATEGORY_NAMES_DICT.food) + .expect("failed to find food category") + .id + .clone(); + + match t { + ActualbudgetTransaction { + is_parent: false, + is_child: false, + parent_id: None, + account_id, + category_id, + amount: -200_00, + date, + .. + } => *account_id == bank_account_id + && *category_id == Some(income_category_id) + && date.to_iso().get(0..10) == Some(format!("2024-{}-08", month).as_str()), + _ => false, + } +} + +fn matches_category_transfer_parent_transaction( + t: &ActualbudgetTransaction, + accounts: &HashMap<String, ActualbudgetAccount>, + _categories: &HashMap<String, ActualbudgetCategory>, +) -> bool { + let category_transfers_account_id = accounts + .get(ACCOUNT_NAMES_DICT.transfers_account) + .expect("failed to find transfers account") + .id + .clone(); + + match t { + ActualbudgetTransaction { + is_parent: true, + is_child: false, + parent_id: None, + account_id, + category_id: None, + amount: 0, + date, + .. + } => *account_id == category_transfers_account_id + && date.to_iso().get(0..10) == Some("2024-10-23"), + _ => false, + } +} + +fn matches_category_transfer_child_out_transaction( + t: &ActualbudgetTransaction, + accounts: &HashMap<String, ActualbudgetAccount>, + categories: &HashMap<String, ActualbudgetCategory>, +) -> bool { + let category_transfers_account_id = accounts + .get(ACCOUNT_NAMES_DICT.transfers_account) + .expect("failed to find transfers account") + .id + .clone(); + + let savings_category_id = categories + .get(CATEGORY_NAMES_DICT.savings) + .expect("failed to find savings category") + .id + .clone(); + + match t { + ActualbudgetTransaction { + is_parent: false, + is_child: true, + parent_id: Some(_), + account_id, + category_id: Some(category_id), + amount: -120_00, + date, + .. + } => *account_id == category_transfers_account_id + && *category_id == savings_category_id + && date.to_iso().get(0..10) == Some("2024-10-23"), + _ => false, + } +} + +fn matches_category_transfer_child_in_transaction( + t: &ActualbudgetTransaction, + accounts: &HashMap<String, ActualbudgetAccount>, + categories: &HashMap<String, ActualbudgetCategory>, +) -> bool { + let category_transfers_account_id = accounts + .get(ACCOUNT_NAMES_DICT.transfers_account) + .expect("failed to find transfers account") + .id + .clone(); + + let savings_category_id = categories + .get(CATEGORY_NAMES_DICT.general) + .expect("failed to find general category") + .id + .clone(); + + match t { + ActualbudgetTransaction { + is_parent: false, + is_child: true, + parent_id: Some(_), + account_id, + category_id: Some(category_id), + amount: 120_00, + date, + .. + } => *account_id == category_transfers_account_id + && *category_id == savings_category_id + && date.to_iso().get(0..10) == Some("2024-10-23"), + _ => false, + } +} + +fn matches_account_transfer_out_transaction( + t: &ActualbudgetTransaction, + accounts: &HashMap<String, ActualbudgetAccount>, + _categories: &HashMap<String, ActualbudgetCategory>, +) -> bool { + let bank_account_id = accounts + .get(ACCOUNT_NAMES_DICT.bank_account) + .expect("failed to find bank account") + .id + .clone(); + + match t { + ActualbudgetTransaction { + is_parent: false, + is_child: false, + parent_id: None, + account_id, + category_id: None, + amount: -80_00, + date, + .. + } => *account_id == bank_account_id + && date.to_iso().get(0..10) == Some("2024-09-10"), + _ => false, + } +} + +fn matches_account_transfer_in_transaction( + t: &ActualbudgetTransaction, + accounts: &HashMap<String, ActualbudgetAccount>, + _categories: &HashMap<String, ActualbudgetCategory>, +) -> bool { + let cash_account_id = accounts + .get(ACCOUNT_NAMES_DICT.cash_account) + .expect("failed to find cash account") + .id + .clone(); + + match t { + ActualbudgetTransaction { + is_parent: false, + is_child: false, + parent_id: None, + account_id, + category_id: None, + amount: 80_00, + date, + .. + } => *account_id == cash_account_id + && date.to_iso().get(0..10) == Some("2024-09-10"), + _ => false, + } +} diff --git a/actualbudget_queries/tests/common/zero_budget_matchers.rs b/actualbudget_queries/tests/common/zero_budget_matchers.rs new file mode 100644 index 0000000..ac88d3b --- /dev/null +++ b/actualbudget_queries/tests/common/zero_budget_matchers.rs @@ -0,0 +1,285 @@ +use std::collections::HashMap; + +use actualbudget_models::{ActualbudgetCategory, ActualbudgetZeroBudget}; + +use super::category_names::CATEGORY_NAMES_DICT; + +pub fn get_zero_budget_matchers() -> Vec<Box<dyn Fn(&ActualbudgetZeroBudget, &HashMap<String, ActualbudgetCategory>) -> bool>> { + vec![ + Box::new(|t, c| matches_food_budget(t, c, "08")), + Box::new(|t, c| matches_food_budget(t, c, "09")), + Box::new(|t, c| matches_food_budget(t, c, "10")), + Box::new(|t, c| matches_food_budget(t, c, "11")), + Box::new(|t, c| matches_zero_food_budget(t, c, "12")), + Box::new(|t, c| matches_general_budget(t, c, "08")), + Box::new(|t, c| matches_general_budget(t, c, "09")), + Box::new(|t, c| matches_general_budget(t, c, "10")), + Box::new(|t, c| matches_general_budget(t, c, "11")), + Box::new(|t, c| matches_zero_general_budget(t, c, "12")), + Box::new(|t, c| matches_bills_budget(t, c, "08")), + Box::new(|t, c| matches_bills_budget(t, c, "09")), + Box::new(|t, c| matches_bills_budget(t, c, "10")), + Box::new(|t, c| matches_bills_budget(t, c, "11")), + Box::new(|t, c| matches_zero_bills_budget(t, c, "12")), + Box::new(|t, c| matches_bills_flexible_budget(t, c, "08")), + Box::new(|t, c| matches_bills_flexible_budget(t, c, "09")), + Box::new(|t, c| matches_bills_flexible_budget(t, c, "10")), + Box::new(|t, c| matches_bills_flexible_budget(t, c, "11")), + Box::new(|t, c| matches_zero_bills_flexible_budget(t, c, "12")), + Box::new(|t, c| matches_savings_budget(t, c, "08")), + Box::new(|t, c| matches_savings_budget(t, c, "09")), + Box::new(|t, c| matches_savings_budget(t, c, "10")), + Box::new(|t, c| matches_savings_budget(t, c, "11")), + Box::new(|t, c| matches_zero_savings_budget(t, c, "12")), + ] +} + +fn matches_food_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let food_category_id = categories + .get(CATEGORY_NAMES_DICT.food) + .expect("failed to find food category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 200_00, + do_carry_over: false, + .. + } => + *category_id == food_category_id + && date.month() == month, + _ => false, + } +} + +fn matches_zero_food_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let food_category_id = categories + .get(CATEGORY_NAMES_DICT.food) + .expect("failed to find food category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 0, + do_carry_over: false, + .. + } => + *category_id == food_category_id + && date.month() == month, + _ => false, + } +} + +fn matches_general_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let general_category_id = categories + .get(CATEGORY_NAMES_DICT.general) + .expect("failed to find general category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 100_00, + do_carry_over: false, + .. + } => + *category_id == general_category_id + && date.month() == month, + _ => false, + } +} + +fn matches_zero_general_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let general_category_id = categories + .get(CATEGORY_NAMES_DICT.general) + .expect("failed to find general category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 0, + do_carry_over: false, + .. + } => + *category_id == general_category_id + && date.month() == month, + _ => false, + } +} + +fn matches_bills_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let bills_category_id = categories + .get(CATEGORY_NAMES_DICT.bills) + .expect("failed to find bills category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 125_00, + do_carry_over: false, + .. + } => + *category_id == bills_category_id + && date.month() == month, + _ => false, + } +} + +fn matches_zero_bills_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let bills_category_id = categories + .get(CATEGORY_NAMES_DICT.bills) + .expect("failed to find bills category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 0, + do_carry_over: false, + .. + } => + *category_id == bills_category_id + && date.month() == month, + _ => false, + } +} + +fn matches_bills_flexible_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let bills_flexible_category_id = categories + .get(CATEGORY_NAMES_DICT.bills_flexible) + .expect("failed to find bills (flexible) category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 25_00, + do_carry_over: false, + .. + } => + *category_id == bills_flexible_category_id + && date.month() == month, + _ => false, + } +} + +fn matches_zero_bills_flexible_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let bills_flexible_category_id = categories + .get(CATEGORY_NAMES_DICT.bills_flexible) + .expect("failed to find bills (flexible) category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 0, + do_carry_over: false, + .. + } => + *category_id == bills_flexible_category_id + && date.month() == month, + _ => false, + } +} + +fn matches_savings_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let savings_category_id = categories + .get(CATEGORY_NAMES_DICT.savings) + .expect("failed to find savings category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 400_00, + do_carry_over: false, + .. + } => + *category_id == savings_category_id + && date.month() == month, + _ => false, + } +} + +fn matches_zero_savings_budget( + t: &ActualbudgetZeroBudget, + categories: &HashMap<String, ActualbudgetCategory>, + month: &str, +) -> bool { + let savings_category_id = categories + .get(CATEGORY_NAMES_DICT.savings) + .expect("failed to find savings category") + .id + .clone(); + + match t { + ActualbudgetZeroBudget { + month: date, + category_id, + amount: 0, + do_carry_over: false, + .. + } => + *category_id == savings_category_id + && date.month() == month, + _ => false, + } +} diff --git a/actualbudget_queries/tests/resources/actualbudget_test_db.sqlite b/actualbudget_queries/tests/resources/actualbudget_test_db.sqlite Binary files differnew file mode 100644 index 0000000..4146bb0 --- /dev/null +++ b/actualbudget_queries/tests/resources/actualbudget_test_db.sqlite diff --git a/actualbudget_queries/tests/resources/actualbudget_test_db_metadata.json b/actualbudget_queries/tests/resources/actualbudget_test_db_metadata.json new file mode 100644 index 0000000..4d83f81 --- /dev/null +++ b/actualbudget_queries/tests/resources/actualbudget_test_db_metadata.json @@ -0,0 +1 @@ +{"id":"My-Finances-1-36ec11a","budgetName":"My Finances 1","userId":"fec9ba17-c260-4f55-8d44-676ef0ec5da9","lastScheduleRun":"2024-11-21","resetClock":true}
\ No newline at end of file |
