summaryrefslogtreecommitdiff
path: root/rust/schist_queries/src
diff options
context:
space:
mode:
Diffstat (limited to 'rust/schist_queries/src')
-rw-r--r--rust/schist_queries/src/accounts.rs41
-rw-r--r--rust/schist_queries/src/budget_updates.rs74
-rw-r--r--rust/schist_queries/src/categories.rs155
-rw-r--r--rust/schist_queries/src/category_transfers.rs74
-rw-r--r--rust/schist_queries/src/clear.rs21
-rw-r--r--rust/schist_queries/src/lib.rs9
-rw-r--r--rust/schist_queries/src/transaction_categorisations.rs59
-rw-r--r--rust/schist_queries/src/transactions.rs41
-rw-r--r--rust/schist_queries/src/utils/calculate_budgets_accrual.rs156
-rw-r--r--rust/schist_queries/src/utils/calculate_category_balance.rs150
-rw-r--r--rust/schist_queries/src/utils/find_by_id_or.rs41
-rw-r--r--rust/schist_queries/src/utils/mod.rs5
12 files changed, 826 insertions, 0 deletions
diff --git a/rust/schist_queries/src/accounts.rs b/rust/schist_queries/src/accounts.rs
new file mode 100644
index 0000000..e8a4f53
--- /dev/null
+++ b/rust/schist_queries/src/accounts.rs
@@ -0,0 +1,41 @@
+use anyhow::{Context, Result};
+use diesel::{QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection};
+
+use schist_models::account::Account;
+use schist_schema::schema::accounts::dsl::accounts as accounts_table;
+
+pub fn delete_all_accounts(connection: &mut SqliteConnection) -> Result<usize> {
+ let num_rows_deleted = diesel::delete(accounts_table)
+ .execute(connection)
+ .with_context(|| "failed to delete all accounts")?;
+ Ok(num_rows_deleted)
+}
+
+pub fn get_all_accounts(connection: &mut SqliteConnection) -> Result<Vec<Account>> {
+ let all_accounts = accounts_table.select(Account::as_select())
+ .load(connection)
+ .with_context(|| "failed to get all accounts")?;
+ Ok(all_accounts)
+}
+
+pub fn insert_accounts(
+ accounts: &[Account],
+ connection: &mut SqliteConnection,
+) -> Result<usize> {
+ let num_accounts_inserted = diesel::insert_into(accounts_table)
+ .values(accounts)
+ .execute(connection)
+ .with_context(|| insert_err_msg(accounts))?;
+ Ok(num_accounts_inserted)
+}
+
+fn insert_err_msg(accounts: &[Account]) -> String {
+ format!(
+ "failed to insert accounts: [{}]",
+ accounts
+ .iter()
+ .map(|a| format!("\"{}\": \"{}\"", a.id, a.name))
+ .collect::<Vec<String>>()
+ .join(", ")
+ )
+}
diff --git a/rust/schist_queries/src/budget_updates.rs b/rust/schist_queries/src/budget_updates.rs
new file mode 100644
index 0000000..07d4808
--- /dev/null
+++ b/rust/schist_queries/src/budget_updates.rs
@@ -0,0 +1,74 @@
+use anyhow::{Context, Result};
+use diesel::{dsl::max, ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection};
+use schist_models::{budget_update::BudgetUpdate, date_utc::DateUtc};
+use schist_schema::schema::budget_updates::{
+ self as budget_updates_schema, dsl::budget_updates as budget_updates_table,
+};
+use schist_traits::nowlike::Nowlike;
+
+pub fn delete_all_budget_updates(connection: &mut SqliteConnection) -> Result<usize> {
+ let num_rows_deleted = diesel::delete(budget_updates_table)
+ .execute(connection)
+ .with_context(|| "failed to delete all budget updates")?;
+ Ok(num_rows_deleted)
+}
+
+pub fn get_all_budget_updates(
+ connection: &mut SqliteConnection,
+) -> Result<Vec<BudgetUpdate>> {
+ let all_budget_updates = budget_updates_table
+ .select(BudgetUpdate::as_select())
+ .load::<BudgetUpdate>(connection)
+ .with_context(|| "failed to get all budget updates")?;
+ Ok(all_budget_updates)
+}
+
+pub fn get_first_budget_update_with_date_and_category_id(
+ date: &DateUtc,
+ category_id: i32,
+ connection: &mut SqliteConnection,
+) -> Result<Option<BudgetUpdate>> {
+ let first_budget_update = budget_updates_table
+ .select(BudgetUpdate::as_select())
+ .filter(budget_updates_schema::date.eq(date.to_string()))
+ .filter(budget_updates_schema::category_id.eq(category_id))
+ .first(connection)
+ .optional()
+ .with_context(|| format!("failed to get first budget update with date {}", date.to_string()))?;
+ Ok(first_budget_update)
+}
+
+pub fn get_most_recent_budget_update_date_with_category_id(
+ category_id: i32,
+ connection: &mut SqliteConnection,
+) -> Result<Option<DateUtc>> {
+ let most_recent_date = budget_updates_table
+ .select(max(budget_updates_schema::date))
+ .filter(budget_updates_schema::category_id.eq(category_id))
+ .filter(budget_updates_schema::date.le(DateUtc::now()))
+ .first(connection)
+ .with_context(|| format!("failed to get most recent budget update date with category ID {}", category_id))?;
+ Ok(most_recent_date)
+}
+
+pub fn insert_budget_updates(
+ budget_updates: &[BudgetUpdate],
+ connection: &mut SqliteConnection,
+) -> Result<usize> {
+ let num_rows_inserted = diesel::insert_into(budget_updates_table)
+ .values(budget_updates)
+ .execute(connection)
+ .with_context(|| insert_err_msg(budget_updates))?;
+ Ok(num_rows_inserted)
+}
+
+fn insert_err_msg(budget_updates: &[BudgetUpdate]) -> String {
+ format!(
+ "failed to insert budget updates: [{}]",
+ budget_updates
+ .iter()
+ .map(|a| a.id.to_string())
+ .collect::<Vec<String>>()
+ .join(", ")
+ )
+}
diff --git a/rust/schist_queries/src/categories.rs b/rust/schist_queries/src/categories.rs
new file mode 100644
index 0000000..f54373c
--- /dev/null
+++ b/rust/schist_queries/src/categories.rs
@@ -0,0 +1,155 @@
+use anyhow::{Context, Result};
+use diesel::{QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection};
+use schist_models::{budget::Budget, category::Category, category_balance::CategoryBalance, category_budget::CategoryBudget, date_utc::DateUtc};
+use schist_schema::schema::categories::{self as categories_schema, dsl::categories as categories_table};
+
+use crate::{
+ budget_updates::{
+ get_all_budget_updates,
+ get_first_budget_update_with_date_and_category_id,
+ get_most_recent_budget_update_date_with_category_id,
+ },
+ category_transfers::{
+ sum_category_transfer_quantity_per_from_category_id,
+ sum_category_transfer_quantity_per_to_category_id,
+ },
+ transaction_categorisations::sum_transaction_categorisation_quantity_per_category_id,
+ utils::calculate_category_balance,
+};
+
+pub fn delete_all_categories(connection: &mut SqliteConnection) -> Result<usize> {
+ let num_rows_deleted = diesel::delete(categories_table)
+ .execute(connection)
+ .with_context(|| "failed to delete all categories")?;
+ Ok(num_rows_deleted)
+}
+
+pub fn get_all_categories(connection: &mut SqliteConnection) -> Result<Vec<Category>> {
+ let all_categories = categories_table
+ .select(Category::as_select())
+ .load(connection)
+ .with_context(|| "failed to get all categories")?;
+ Ok(all_categories)
+}
+
+pub fn get_all_category_balances(
+ connection: &mut SqliteConnection,
+) -> Result<Vec<CategoryBalance>> {
+ get_all_category_balances_without_context(connection)
+ .with_context(|| "failed to get all category balances")
+}
+
+fn get_all_category_balances_without_context(
+ connection: &mut SqliteConnection,
+) -> Result<Vec<CategoryBalance>> {
+ let categories = get_all_categories(connection)?;
+ let transaction_sums = sum_transaction_categorisation_quantity_per_category_id(connection)?;
+ let category_transfer_from_sums =
+ sum_category_transfer_quantity_per_from_category_id(connection)?;
+ let category_transfer_to_sums = sum_category_transfer_quantity_per_to_category_id(connection)?;
+ let budget_updates = get_all_budget_updates(connection)?;
+
+ let mut balances = Vec::<CategoryBalance>::new();
+ for category in categories {
+ let balance = calculate_category_balance(
+ &category,
+ &transaction_sums,
+ &category_transfer_from_sums,
+ &category_transfer_to_sums,
+ &budget_updates,
+ )?;
+
+ balances.push(CategoryBalance {
+ category_id: category.id,
+ balance: balance.try_into().unwrap(),
+ })
+ }
+
+ Ok(balances)
+}
+
+pub fn get_all_category_ids(connection: &mut SqliteConnection) -> Result<Vec<i32>> {
+ let all_category_ids = categories_table
+ .select(categories_schema::id)
+ .load(connection)
+ .with_context(|| "failed to get all category IDs")?;
+ Ok(all_category_ids)
+}
+
+pub fn get_most_recent_category_budget_per_category_id(
+ connection: &mut SqliteConnection,
+) -> Result<Vec<CategoryBudget>> {
+ get_most_recent_category_budget_per_category_id_without_context(connection)
+ .with_context(|| "failed to get most recent category budget per category ID")
+}
+
+fn get_most_recent_category_budget_per_category_id_without_context(
+ connection: &mut SqliteConnection,
+) -> Result<Vec<CategoryBudget>> {
+ let category_ids: Vec<i32> = get_all_category_ids(connection)?;
+
+ let mut budgets = Vec::<CategoryBudget>::new();
+ for category_id in category_ids {
+ let most_recent_budget_update_date =
+ get_most_recent_budget_update_date_with_category_id(category_id, connection)?;
+
+ budgets.push(match most_recent_budget_update_date {
+ None => CategoryBudget {
+ category_id,
+ budget: Option::None,
+ },
+ Some(most_recent_budget_update_date) =>
+ get_most_recent_category_budget_with_date_and_category_id(
+ &most_recent_budget_update_date,
+ category_id,
+ connection,
+ )?,
+ })
+ }
+
+ Ok(budgets)
+}
+
+fn get_most_recent_category_budget_with_date_and_category_id(
+ most_recent_budget_update_date: &DateUtc,
+ category_id: i32,
+ connection: &mut SqliteConnection
+) -> Result<CategoryBudget> {
+ let most_recent_budget_update = get_first_budget_update_with_date_and_category_id(
+ &most_recent_budget_update_date,
+ category_id,
+ connection,
+ )?;
+
+ let budget = match most_recent_budget_update {
+ None => None,
+ Some(most_recent_budget_update) => Some(Budget::from(&most_recent_budget_update)),
+ };
+
+ Ok(CategoryBudget {
+ category_id,
+ budget,
+ })
+}
+
+pub fn insert_categories(
+ categories: &[Category],
+ connection: &mut SqliteConnection,
+) -> Result<usize> {
+ let num_categories_inserted = diesel::insert_into(categories_table)
+ .values(categories)
+ .execute(connection)
+ .with_context(|| insert_err_msg(categories))?;
+ Ok(num_categories_inserted)
+}
+
+fn insert_err_msg(categories: &[Category]) -> String {
+ format!(
+ "failed to insert categories: [{}]",
+ categories
+ .iter()
+ .map(|c| format!("\"{}\": \"{}\"", c.id, c.name))
+ .collect::<Vec<String>>()
+ .join(", ")
+ )
+}
diff --git a/rust/schist_queries/src/category_transfers.rs b/rust/schist_queries/src/category_transfers.rs
new file mode 100644
index 0000000..33a53ea
--- /dev/null
+++ b/rust/schist_queries/src/category_transfers.rs
@@ -0,0 +1,74 @@
+use anyhow::{Context, Result};
+use diesel::{dsl::sum, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection};
+use schist_schema::schema::category_transfers::{
+ self as category_transfers_schema,
+ dsl::category_transfers as category_transfers_table,
+};
+use schist_models::category_transfer::CategoryTransfer;
+
+pub fn delete_all_category_transfers(connection: &mut SqliteConnection) -> Result<usize> {
+ let num_rows_deleted = diesel::delete(category_transfers_table)
+ .execute(connection)
+ .with_context(|| "failed to delete all category transfers")?;
+ Ok(num_rows_deleted)
+}
+
+pub fn get_all_category_transfers(connection: &mut SqliteConnection) -> Result<Vec<CategoryTransfer>> {
+ let all_category_transfers = category_transfers_table
+ .select(CategoryTransfer::as_select())
+ .load(connection)
+ .with_context(|| "failed to get all category transfers")?;
+ Ok(all_category_transfers)
+}
+
+pub fn insert_category_transfers(
+ category_transfers: &[CategoryTransfer],
+ connection: &mut SqliteConnection,
+) -> Result<usize> {
+ let num_rows_inserted = diesel::insert_into(category_transfers_table)
+ .values(category_transfers)
+ .execute(connection)
+ .with_context(|| insert_err_msg(&category_transfers))?;
+ Ok(num_rows_inserted)
+}
+
+fn insert_err_msg(category_transfers: &[CategoryTransfer]) -> String {
+ format!(
+ "failed to insert category transfers: [{}]",
+ category_transfers
+ .iter()
+ .map(|ct| ct.id.to_string())
+ .collect::<Vec<String>>()
+ .join(", ")
+ )
+}
+
+pub fn sum_category_transfer_quantity_per_from_category_id(
+ connection: &mut SqliteConnection,
+) -> Result<Vec<(i32, i64)>> {
+ let sum = category_transfers_table
+ .group_by(category_transfers_schema::from_category_id)
+ .select((
+ category_transfers_schema::from_category_id,
+ sum(category_transfers_schema::quantity),
+ ))
+ .load::<(i32, Option<i64>)>(connection)
+ .map(|result| result.iter().map(|sum| (sum.0, sum.1.unwrap_or(0))).collect())
+ .with_context(|| "failed to sum category transfer quantity per from category ID")?;
+ Ok(sum)
+}
+
+pub fn sum_category_transfer_quantity_per_to_category_id(
+ connection: &mut SqliteConnection,
+) -> Result<Vec<(i32, i64)>> {
+ let sum = category_transfers_table
+ .group_by(category_transfers_schema::to_category_id)
+ .select((
+ category_transfers_schema::to_category_id,
+ sum(category_transfers_schema::quantity),
+ ))
+ .load::<(i32, Option<i64>)>(connection)
+ .map(|vec| vec.iter().map(|(id, sum)| (*id, sum.unwrap_or(0))).collect())
+ .with_context(|| "failed to sum category transfer quantity per to category ID")?;
+ Ok(sum)
+}
diff --git a/rust/schist_queries/src/clear.rs b/rust/schist_queries/src/clear.rs
new file mode 100644
index 0000000..c823bcc
--- /dev/null
+++ b/rust/schist_queries/src/clear.rs
@@ -0,0 +1,21 @@
+use anyhow::Result;
+use diesel::SqliteConnection;
+
+use crate::{
+ accounts::delete_all_accounts,
+ budget_updates::delete_all_budget_updates,
+ categories::delete_all_categories,
+ category_transfers::delete_all_category_transfers,
+ transactions::delete_all_transactions,
+ transaction_categorisations::delete_all_transaction_categorisations,
+};
+
+pub fn clear(connection: &mut SqliteConnection) -> Result<()> {
+ delete_all_accounts(connection)?;
+ delete_all_budget_updates(connection)?;
+ delete_all_categories(connection)?;
+ delete_all_category_transfers(connection)?;
+ delete_all_transactions(connection)?;
+ delete_all_transaction_categorisations(connection)?;
+ Ok(())
+}
diff --git a/rust/schist_queries/src/lib.rs b/rust/schist_queries/src/lib.rs
new file mode 100644
index 0000000..3cae464
--- /dev/null
+++ b/rust/schist_queries/src/lib.rs
@@ -0,0 +1,9 @@
+pub mod accounts;
+pub mod budget_updates;
+pub mod categories;
+pub mod category_transfers;
+pub mod clear;
+pub mod transactions;
+pub mod transaction_categorisations;
+
+mod utils;
diff --git a/rust/schist_queries/src/transaction_categorisations.rs b/rust/schist_queries/src/transaction_categorisations.rs
new file mode 100644
index 0000000..320782d
--- /dev/null
+++ b/rust/schist_queries/src/transaction_categorisations.rs
@@ -0,0 +1,59 @@
+use anyhow::{Context, Result};
+use diesel::{dsl::sum, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection};
+use schist_models::transaction_categorisation::TransactionCategorisation;
+use schist_schema::schema::transaction_categorisations::{
+ self as transaction_categorisations_schema,
+ dsl::transaction_categorisations as transaction_categorisations_table,
+};
+
+pub fn delete_all_transaction_categorisations(connection: &mut SqliteConnection) -> Result<usize> {
+ let num_rows_deleted = diesel::delete(transaction_categorisations_table)
+ .execute(connection)
+ .with_context(|| "failed to delete all transaction categorisations")?;
+ Ok(num_rows_deleted)
+}
+
+pub fn get_all_transaction_categorisations(connection: &mut SqliteConnection) -> Result<Vec<TransactionCategorisation>> {
+ let all_transaction_categorisations = transaction_categorisations_table
+ .select(TransactionCategorisation::as_select())
+ .load(connection)
+ .with_context(|| "failed to get all transaction categorisations")?;
+ Ok(all_transaction_categorisations)
+}
+
+pub fn insert_transaction_categorisations(
+ transaction_categorisations: &[TransactionCategorisation],
+ connection: &mut SqliteConnection,
+) -> Result<usize> {
+ let num_rows_inserted = diesel::insert_into(transaction_categorisations_table)
+ .values(transaction_categorisations)
+ .execute(connection)
+ .with_context(|| insert_err_msg(&transaction_categorisations))?;
+ Ok(num_rows_inserted)
+}
+
+fn insert_err_msg(transaction_categorisations: &[TransactionCategorisation]) -> String {
+ format!(
+ "failed to insert transaction categorisations: [{}]",
+ transaction_categorisations
+ .iter()
+ .map(|tc| tc.id.to_string())
+ .collect::<Vec<String>>()
+ .join(", ")
+ )
+}
+
+pub fn sum_transaction_categorisation_quantity_per_category_id(
+ connection: &mut SqliteConnection,
+) -> Result<Vec<(i32, i64)>> {
+ let sum = transaction_categorisations_table
+ .group_by(transaction_categorisations_schema::category_id)
+ .select((
+ transaction_categorisations_schema::category_id,
+ sum(transaction_categorisations_schema::quantity),
+ ))
+ .load::<(i32, Option<i64>)>(connection)
+ .map(|vec| vec.iter().map(|(category_id, sum)| (*category_id, sum.unwrap_or(0))).collect())
+ .with_context(|| "failed to sum transaction categorisation quantity per category ID")?;
+ Ok(sum)
+}
diff --git a/rust/schist_queries/src/transactions.rs b/rust/schist_queries/src/transactions.rs
new file mode 100644
index 0000000..70b14e7
--- /dev/null
+++ b/rust/schist_queries/src/transactions.rs
@@ -0,0 +1,41 @@
+use anyhow::{Context, Result};
+use diesel::{QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection};
+use schist_models::transaction::Transaction;
+use schist_schema::schema::transactions::dsl::transactions as transactions_table;
+
+pub fn delete_all_transactions(connection: &mut SqliteConnection) -> Result<usize> {
+ let num_rows_deleted = diesel::delete(transactions_table)
+ .execute(connection)
+ .with_context(|| "failed to delete all transactions")?;
+ Ok(num_rows_deleted)
+}
+
+pub fn get_all_transactions(connection: &mut SqliteConnection) -> Result<Vec<Transaction>> {
+ let all_transactions = transactions_table
+ .select(Transaction::as_select())
+ .load(connection)
+ .with_context(|| "failed to get all category transfers")?;
+ Ok(all_transactions)
+}
+
+pub fn insert_transactions(
+ transactions: &[Transaction],
+ connection: &mut SqliteConnection,
+) -> Result<usize> {
+ let num_rows_inserted = diesel::insert_into(transactions_table)
+ .values(transactions)
+ .execute(connection)
+ .with_context(|| insert_err_msg(&transactions))?;
+ Ok(num_rows_inserted)
+}
+
+fn insert_err_msg(transactions: &[Transaction]) -> String {
+ format!(
+ "failed to insert transactions: [{}]",
+ transactions
+ .iter()
+ .map(|t| t.id.to_string())
+ .collect::<Vec<String>>()
+ .join(", ")
+ )
+}
diff --git a/rust/schist_queries/src/utils/calculate_budgets_accrual.rs b/rust/schist_queries/src/utils/calculate_budgets_accrual.rs
new file mode 100644
index 0000000..1d4ed02
--- /dev/null
+++ b/rust/schist_queries/src/utils/calculate_budgets_accrual.rs
@@ -0,0 +1,156 @@
+use anyhow::Result;
+use schist_models::{
+ budget_update::BudgetUpdate, category::Category, date_utc::DateUtc,
+};
+use schist_traits::nowlike::Nowlike;
+
+pub fn calculate_budgets_accrual(category: &Category, budget_updates: &[BudgetUpdate]) -> Result<i64> {
+ let mut budget_updates = budget_updates
+ .iter()
+ .filter(|bu| bu.category_id == category.id)
+ .collect::<Vec<&BudgetUpdate>>();
+ budget_updates.sort_by_key(|bu| bu.date.clone());
+ budget_updates.reverse();
+
+ let mut budgets_accrual = 0.0_f64;
+ let mut calculated_back_to_date = DateUtc::now();
+
+ for budget_update in budget_updates {
+ let days_on_this_budget = calculated_back_to_date.day_diff(&budget_update.date);
+ if days_on_this_budget == 0_u32 {
+ return Err(anyhow::Error::msg(format!(
+ "failed to calculate budgets accrual since two budget updates had the same date ({})",
+ budget_update.date,
+ )));
+ }
+ let budget_per_day = budget_update.new_budget as f64 / budget_update.new_period as f64;
+ budgets_accrual += days_on_this_budget as f64 * budget_per_day;
+ calculated_back_to_date = budget_update.date.clone();
+ }
+
+ Ok(budgets_accrual.floor() as i64)
+}
+
+#[cfg(test)]
+mod test {
+ use chrono::{Days, Utc};
+ use schist_models::{budget_update::BudgetUpdate, category::Category, date_utc::DateUtc};
+ use schist_traits::nowlike::Nowlike;
+
+ use super::calculate_budgets_accrual;
+
+ #[test]
+ fn when_no_budget_updates_then_return_zero() {
+ let category = Category {
+ id: 0,
+ name: String::from("Groceries"),
+ };
+ let budget_updates = [];
+
+ let result = calculate_budgets_accrual(&category, &budget_updates);
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 0_i64);
+ }
+
+ #[test]
+ fn when_one_budget_update_for_another_category_then_return_zero() {
+ let category = Category {
+ id: 0,
+ name: String::from("Groceries"),
+ };
+ let budget_updates = [BudgetUpdate {
+ id: 0,
+ category_id: 1,
+ date: DateUtc::now(),
+ new_budget: 100,
+ new_period: 1,
+ }];
+
+ let result = calculate_budgets_accrual(&category, &budget_updates);
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 0_i64);
+ }
+
+ #[test]
+ fn when_one_budget_update_then_return_quantity_times_days_in_past() {
+ let category = Category {
+ id: 0,
+ name: String::from("Groceries"),
+ };
+ let budget_updates = [BudgetUpdate {
+ id: 0,
+ category_id: 0,
+ date: DateUtc::from(Utc::now().checked_sub_days(Days::new(120)).unwrap()),
+ new_budget: 100,
+ new_period: 1,
+ }];
+
+ let result = calculate_budgets_accrual(&category, &budget_updates);
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 12_000);
+ }
+
+ #[test]
+ fn when_two_budget_updates_then_calculates_as_expected() {
+ let category = Category {
+ id: 0,
+ name: String::from("Groceries"),
+ };
+ let budget_updates = [
+ BudgetUpdate {
+ id: 0,
+ category_id: 0,
+ date: DateUtc::from(&Utc::now().checked_sub_days(Days::new(60)).unwrap()),
+ new_budget: 200,
+ new_period: 1,
+ },
+ BudgetUpdate {
+ id: 0,
+ category_id: 0,
+ date: DateUtc::from(&Utc::now().checked_sub_days(Days::new(120)).unwrap()),
+ new_budget: 100,
+ new_period: 1,
+ },
+ ];
+
+ let result = calculate_budgets_accrual(&category, &budget_updates);
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 18_000);
+ }
+
+ #[test]
+ fn when_two_budget_updates_on_same_day_then_returns_err() {
+ let category = Category {
+ id: 0,
+ name: String::from("Groceries"),
+ };
+ let budget_updates = [
+ BudgetUpdate {
+ id: 0,
+ category_id: 0,
+ date: DateUtc::from_ymd(2020, 12, 25).unwrap(),
+ new_budget: 200,
+ new_period: 1,
+ },
+ BudgetUpdate {
+ id: 0,
+ category_id: 0,
+ date: DateUtc::from_ymd(2020, 12, 25).unwrap(),
+ new_budget: 100,
+ new_period: 1,
+ },
+ ];
+ let mut budget_updates_reversed = budget_updates.clone();
+ budget_updates_reversed.reverse();
+
+ let result_leftways = calculate_budgets_accrual(&category, &budget_updates);
+ let result_rightways = calculate_budgets_accrual(&category, &budget_updates_reversed);
+
+ assert!(result_leftways.is_err());
+ assert!(result_rightways.is_err());
+ }
+}
diff --git a/rust/schist_queries/src/utils/calculate_category_balance.rs b/rust/schist_queries/src/utils/calculate_category_balance.rs
new file mode 100644
index 0000000..acb56fb
--- /dev/null
+++ b/rust/schist_queries/src/utils/calculate_category_balance.rs
@@ -0,0 +1,150 @@
+use anyhow::Result;
+use schist_models::{budget_update::BudgetUpdate, category::Category};
+
+use super::{calculate_budgets_accrual::calculate_budgets_accrual, find_by_id_or::find_by_id_or};
+
+pub fn calculate_category_balance(
+ category: &Category,
+ transaction_sums: &[(i32, i64)],
+ category_transfer_from_sums: &[(i32, i64)],
+ category_transfer_to_sums: &[(i32, i64)],
+ budget_updates: &[BudgetUpdate],
+) -> Result<i64> {
+ let transaction_sum = find_by_id_or(transaction_sums, category.id, 0);
+ let category_transfer_from_sum = find_by_id_or(category_transfer_from_sums, category.id, 0);
+ let category_transfer_to_sum = find_by_id_or(category_transfer_to_sums, category.id, 0);
+ let budget_accruals_sum = calculate_budgets_accrual(category, budget_updates)?;
+
+ Ok(
+ transaction_sum
+ + budget_accruals_sum
+ + category_transfer_to_sum
+ - category_transfer_from_sum
+ )
+}
+
+#[cfg(test)]
+mod test {
+ use chrono::{Days, Utc};
+ use schist_models::{budget_update::BudgetUpdate, category::Category, date_utc::DateUtc};
+
+ use super::calculate_category_balance;
+
+ #[test]
+ fn when_no_data_then_returns_zero() {
+ let category = Category {
+ id: 0,
+ name: String::new(),
+ };
+
+ let result = calculate_category_balance(&category, &[], &[], &[], &[]);
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 0_i64);
+ }
+
+ #[test]
+ fn when_data_only_for_another_category_then_returns_zero() {
+ let category = Category {
+ id: 0,
+ name: String::new(),
+ };
+ let transaction_sums = [(1, 100)];
+ let category_transfer_from_sums = [(1, 91)];
+ let category_transfer_to_sums = [(2, 79)];
+ let budget_updates = [BudgetUpdate {
+ id: 0,
+ category_id: 1,
+ date: DateUtc::from(Utc::now().checked_sub_days(Days::new(120)).unwrap()),
+ new_budget: 77,
+ new_period: 1,
+ }];
+
+ let result = calculate_category_balance(
+ &category,
+ &transaction_sums,
+ &category_transfer_from_sums,
+ &category_transfer_to_sums,
+ &budget_updates,
+ );
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 0_i64);
+ }
+
+ #[test]
+ fn when_transaction_sum_is_zero_then_returns_zero() {
+ let category = Category {
+ id: 0,
+ name: String::new(),
+ };
+ let transaction_sums = [(0, 0)];
+ let category_transfer_from_sums = [];
+ let category_transfer_to_sums = [];
+ let budget_updates = [];
+
+ let result = calculate_category_balance(
+ &category,
+ &transaction_sums,
+ &category_transfer_from_sums,
+ &category_transfer_to_sums,
+ &budget_updates,
+ );
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 0_i64);
+ }
+
+ #[test]
+ fn when_category_transfers_are_zero_then_returns_zero() {
+ let category = Category {
+ id: 0,
+ name: String::new(),
+ };
+ let transaction_sums = [];
+ let category_transfer_from_sums = [(0, 0)];
+ let category_transfer_to_sums = [(0, 0)];
+ let budget_updates = [];
+
+ let result = calculate_category_balance(
+ &category,
+ &transaction_sums,
+ &category_transfer_from_sums,
+ &category_transfer_to_sums,
+ &budget_updates,
+ );
+
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), 0_i64);
+ }
+
+ #[test]
+ fn when_some_data_then_calculates_as_expected() {
+ let category = Category {
+ id: 0,
+ name: String::new(),
+ };
+ let transaction_sums = [(0, 100)];
+ let category_transfer_from_sums = [(0, 91)];
+ let category_transfer_to_sums = [(0, 79)];
+ let budget_updates = [BudgetUpdate {
+ id: 0,
+ category_id: 0,
+ date: DateUtc::from(&Utc::now().checked_sub_days(Days::new(120)).unwrap()),
+ new_budget: 77,
+ new_period: 1,
+ }];
+
+ let result = calculate_category_balance(
+ &category,
+ &transaction_sums,
+ &category_transfer_from_sums,
+ &category_transfer_to_sums,
+ &budget_updates,
+ );
+
+ let expected_result = 100 + (-91) + 79 + 77 * 120;
+ assert!(result.is_ok());
+ assert_eq!(result.unwrap(), expected_result);
+ }
+}
diff --git a/rust/schist_queries/src/utils/find_by_id_or.rs b/rust/schist_queries/src/utils/find_by_id_or.rs
new file mode 100644
index 0000000..8335e5c
--- /dev/null
+++ b/rust/schist_queries/src/utils/find_by_id_or.rs
@@ -0,0 +1,41 @@
+pub fn find_by_id_or<Id: PartialEq, Val: Copy>(
+ arr: &[(Id, Val)],
+ id: Id,
+ default: Val,
+) -> Val {
+ arr.iter()
+ .find(|(elem_id, _)| *elem_id == id)
+ .map_or(default, |row| row.1)
+}
+
+#[cfg(test)]
+mod test {
+ use super::find_by_id_or;
+
+ #[test]
+ fn when_no_elements_then_return_default() {
+ let arr = [];
+
+ let result = find_by_id_or(&arr, 1, 42);
+
+ assert_eq!(result, 42);
+ }
+
+ #[test]
+ fn when_id_not_in_elements_then_return_default() {
+ let arr = [(0, 100), (2, 200)];
+
+ let result = find_by_id_or(&arr, 1, 42);
+
+ assert_eq!(result, 42);
+ }
+
+ #[test]
+ fn when_id_in_elements_then_return_value() {
+ let arr = [(1, 67)];
+
+ let result = find_by_id_or(&arr, 1, 42);
+
+ assert_eq!(result, 67);
+ }
+}
diff --git a/rust/schist_queries/src/utils/mod.rs b/rust/schist_queries/src/utils/mod.rs
new file mode 100644
index 0000000..6949ce5
--- /dev/null
+++ b/rust/schist_queries/src/utils/mod.rs
@@ -0,0 +1,5 @@
+mod calculate_budgets_accrual;
+mod calculate_category_balance;
+mod find_by_id_or;
+
+pub use calculate_category_balance::calculate_category_balance;