From 27b95c9186bd45edd4c982a7cc89cf69cf2c5d46 Mon Sep 17 00:00:00 2001 From: Joe Carstairs Date: Wed, 16 Oct 2024 12:51:21 +0100 Subject: All backend stuff lives in backend folder --- backend/app/src/databases.rs | 11 ++ backend/app/src/main.rs | 57 ++++++++ backend/app/src/models/account.rs | 15 ++ backend/app/src/models/budget.rs | 44 ++++++ backend/app/src/models/budget_update.rs | 17 +++ backend/app/src/models/category.rs | 9 ++ backend/app/src/models/category_balance.rs | 7 + backend/app/src/models/category_budget.rs | 9 ++ backend/app/src/models/category_transfer.rs | 11 ++ backend/app/src/models/date_time_utc.rs | 131 +++++++++++++++++ backend/app/src/models/mod.rs | 10 ++ backend/app/src/models/transaction.rs | 17 +++ .../app/src/models/transaction_categorisation.rs | 11 ++ backend/app/src/queries/accounts/get_all.rs | 7 + backend/app/src/queries/accounts/insert.rs | 14 ++ backend/app/src/queries/accounts/mod.rs | 5 + backend/app/src/queries/budget_updates/get_all.rs | 13 ++ .../queries/budget_updates/get_first_with_date.rs | 19 +++ .../get_most_recent_with_category_id.rs | 18 +++ backend/app/src/queries/budget_updates/mod.rs | 7 + backend/app/src/queries/categories/get_all.rs | 8 ++ backend/app/src/queries/categories/get_all_ids.rs | 8 ++ backend/app/src/queries/categories/mod.rs | 5 + .../app/src/queries/category_balances/get_all.rs | 43 ++++++ backend/app/src/queries/category_balances/mod.rs | 3 + .../get_most_recent_per_category_id.rs | 42 ++++++ backend/app/src/queries/category_budgets/mod.rs | 3 + backend/app/src/queries/category_transfers/mod.rs | 5 + .../sum_quantity_per_from_category_id.rs | 17 +++ .../sum_quantity_per_to_category_id.rs | 17 +++ backend/app/src/queries/mod.rs | 7 + .../src/queries/transaction_categorisations/mod.rs | 3 + .../sum_quantity_per_category_id.rs | 17 +++ .../sum_quantity_per_to_category_id.rs | 0 backend/app/src/routes/account.rs | 57 ++++++++ backend/app/src/routes/balance.rs | 17 +++ backend/app/src/routes/budget.rs | 18 +++ backend/app/src/routes/budget_update.rs | 18 +++ backend/app/src/routes/category.rs | 16 +++ backend/app/src/routes/mod.rs | 5 + backend/app/src/schema.rs | 72 ++++++++++ backend/app/src/utils/calculate_budgets_accrual.rs | 159 +++++++++++++++++++++ .../app/src/utils/calculate_category_balance.rs | 141 ++++++++++++++++++ backend/app/src/utils/find_by_id_or.rs | 53 +++++++ backend/app/src/utils/mod.rs | 7 + 45 files changed, 1173 insertions(+) create mode 100644 backend/app/src/databases.rs create mode 100644 backend/app/src/main.rs create mode 100644 backend/app/src/models/account.rs create mode 100644 backend/app/src/models/budget.rs create mode 100644 backend/app/src/models/budget_update.rs create mode 100644 backend/app/src/models/category.rs create mode 100644 backend/app/src/models/category_balance.rs create mode 100644 backend/app/src/models/category_budget.rs create mode 100644 backend/app/src/models/category_transfer.rs create mode 100644 backend/app/src/models/date_time_utc.rs create mode 100644 backend/app/src/models/mod.rs create mode 100644 backend/app/src/models/transaction.rs create mode 100644 backend/app/src/models/transaction_categorisation.rs create mode 100644 backend/app/src/queries/accounts/get_all.rs create mode 100644 backend/app/src/queries/accounts/insert.rs create mode 100644 backend/app/src/queries/accounts/mod.rs create mode 100644 backend/app/src/queries/budget_updates/get_all.rs create mode 100644 backend/app/src/queries/budget_updates/get_first_with_date.rs create mode 100644 backend/app/src/queries/budget_updates/get_most_recent_with_category_id.rs create mode 100644 backend/app/src/queries/budget_updates/mod.rs create mode 100644 backend/app/src/queries/categories/get_all.rs create mode 100644 backend/app/src/queries/categories/get_all_ids.rs create mode 100644 backend/app/src/queries/categories/mod.rs create mode 100644 backend/app/src/queries/category_balances/get_all.rs create mode 100644 backend/app/src/queries/category_balances/mod.rs create mode 100644 backend/app/src/queries/category_budgets/get_most_recent_per_category_id.rs create mode 100644 backend/app/src/queries/category_budgets/mod.rs create mode 100644 backend/app/src/queries/category_transfers/mod.rs create mode 100644 backend/app/src/queries/category_transfers/sum_quantity_per_from_category_id.rs create mode 100644 backend/app/src/queries/category_transfers/sum_quantity_per_to_category_id.rs create mode 100644 backend/app/src/queries/mod.rs create mode 100644 backend/app/src/queries/transaction_categorisations/mod.rs create mode 100644 backend/app/src/queries/transaction_categorisations/sum_quantity_per_category_id.rs create mode 100644 backend/app/src/queries/transaction_categorisations/sum_quantity_per_to_category_id.rs create mode 100644 backend/app/src/routes/account.rs create mode 100644 backend/app/src/routes/balance.rs create mode 100644 backend/app/src/routes/budget.rs create mode 100644 backend/app/src/routes/budget_update.rs create mode 100644 backend/app/src/routes/category.rs create mode 100644 backend/app/src/routes/mod.rs create mode 100644 backend/app/src/schema.rs create mode 100644 backend/app/src/utils/calculate_budgets_accrual.rs create mode 100644 backend/app/src/utils/calculate_category_balance.rs create mode 100644 backend/app/src/utils/find_by_id_or.rs create mode 100644 backend/app/src/utils/mod.rs (limited to 'backend/app/src') diff --git a/backend/app/src/databases.rs b/backend/app/src/databases.rs new file mode 100644 index 0000000..7202e78 --- /dev/null +++ b/backend/app/src/databases.rs @@ -0,0 +1,11 @@ +use rocket_sync_db_pools::{database, diesel}; + +cfg_if::cfg_if! { + if #[cfg(test)] { + #[database("user_data.test")] + pub struct UserDataDb(diesel::SqliteConnection); + } else { + #[database("user_data")] + pub struct UserDataDb(diesel::SqliteConnection); + } +} diff --git a/backend/app/src/main.rs b/backend/app/src/main.rs new file mode 100644 index 0000000..c3e02e5 --- /dev/null +++ b/backend/app/src/main.rs @@ -0,0 +1,57 @@ +use diesel::SqliteConnection; +use diesel_migrations::MigrationHarness; +use diesel_migrations::{embed_migrations, EmbeddedMigrations}; +use rocket::fairing::AdHoc; +use rocket::http::ContentType; +use rocket::{launch, routes, Build, Rocket}; + +use crate::databases::UserDataDb; + +pub mod databases; +pub mod models; +pub mod queries; +pub mod routes; +pub mod schema; +pub mod utils; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); + +fn run_database_migrations(connection: &mut SqliteConnection) { + connection + .run_pending_migrations(MIGRATIONS) + .expect("Error running database migrations"); +} + +#[launch] +async fn rocket() -> Rocket { + rocket::build() + .attach(UserDataDb::fairing()) + .attach(AdHoc::on_liftoff("Database Migration", |rocket| { + Box::pin(async move { + let user_data_db = UserDataDb::get_one(rocket).await.expect( + "Failed to get connection to user data database in order to run migrations", + ); + user_data_db.run(run_database_migrations).await; + }) + })) + .attach(AdHoc::on_response("Add CORS headers", |_req, res| { + Box::pin(async move { + res.set_header(ContentType::JSON); + res.set_header(rocket::http::Header::new( + "Access-Control-Allow-Origin", + "http://localhost:5173", + )); + }) + })) + .mount( + "/", + routes![ + routes::account::get_all_accounts, + routes::account::create_account, + routes::balance::get_all_category_balances, + routes::budget::get_current_category_budget_per_category, + routes::budget_update::get_all_budget_updates, + routes::category::get_all_categories, + ], + ) +} diff --git a/backend/app/src/models/account.rs b/backend/app/src/models/account.rs new file mode 100644 index 0000000..c10bd5f --- /dev/null +++ b/backend/app/src/models/account.rs @@ -0,0 +1,15 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::date_time_utc::DateTimeUtc; + +#[derive( + Insertable, Queryable, Identifiable, Selectable, Debug, PartialEq, Clone, Serialize, Deserialize, +)] +#[diesel(table_name = crate::schema::accounts)] +pub struct Account { + pub id: i32, + pub name: String, + pub opening_balance: i32, + pub opening_date: DateTimeUtc, +} diff --git a/backend/app/src/models/budget.rs b/backend/app/src/models/budget.rs new file mode 100644 index 0000000..cc18f2e --- /dev/null +++ b/backend/app/src/models/budget.rs @@ -0,0 +1,44 @@ +use serde::Serialize; + +use super::budget_update::BudgetUpdate; + +#[derive(Debug, Eq, PartialEq, Serialize)] +pub struct Budget { + pub quantity: i32, + pub period: i32, +} + +impl From<&BudgetUpdate> for Budget { + fn from(budget_update: &BudgetUpdate) -> Self { + Self { + quantity: budget_update.new_budget, + period: budget_update.new_period, + } + } +} + +#[cfg(test)] +mod test { + use chrono::Utc; + + use crate::models::date_time_utc::DateTimeUtc; + + use super::*; + + #[test] + fn when_from_budget_update_then_returns_budget() { + let quantity = 2; + let period = 3; + let budget_update = BudgetUpdate { + id: 0, + category_id: 1, + date: DateTimeUtc::new(Utc::now()), + new_budget: quantity, + new_period: period, + }; + + let budget = Budget::from(&budget_update); + + assert_eq!(budget, Budget { quantity, period }); + } +} diff --git a/backend/app/src/models/budget_update.rs b/backend/app/src/models/budget_update.rs new file mode 100644 index 0000000..c63e957 --- /dev/null +++ b/backend/app/src/models/budget_update.rs @@ -0,0 +1,17 @@ +use diesel::prelude::*; +use serde::Serialize; + +use crate::models::category::Category; + +use super::date_time_utc::DateTimeUtc; + +#[derive(Clone, Serialize, Queryable, Identifiable, Selectable, Associations, Debug, PartialEq)] +#[diesel(table_name = crate::schema::budget_updates)] +#[diesel(belongs_to(Category))] +pub struct BudgetUpdate { + pub id: i32, + pub category_id: i32, + pub date: DateTimeUtc, + pub new_budget: i32, + pub new_period: i32, +} diff --git a/backend/app/src/models/category.rs b/backend/app/src/models/category.rs new file mode 100644 index 0000000..5cb2609 --- /dev/null +++ b/backend/app/src/models/category.rs @@ -0,0 +1,9 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Queryable, Identifiable, Selectable, Debug, PartialEq, Serialize, Deserialize)] +#[diesel(table_name = crate::schema::categories)] +pub struct Category { + pub id: i32, + pub name: String, +} diff --git a/backend/app/src/models/category_balance.rs b/backend/app/src/models/category_balance.rs new file mode 100644 index 0000000..796b40b --- /dev/null +++ b/backend/app/src/models/category_balance.rs @@ -0,0 +1,7 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct CategoryBalance { + pub category_id: i32, + pub balance: i32, +} diff --git a/backend/app/src/models/category_budget.rs b/backend/app/src/models/category_budget.rs new file mode 100644 index 0000000..9796261 --- /dev/null +++ b/backend/app/src/models/category_budget.rs @@ -0,0 +1,9 @@ +use serde::Serialize; + +use super::budget::Budget; + +#[derive(Serialize)] +pub struct CategoryBudget { + pub category_id: i32, + pub budget: Option, +} diff --git a/backend/app/src/models/category_transfer.rs b/backend/app/src/models/category_transfer.rs new file mode 100644 index 0000000..6cbda0e --- /dev/null +++ b/backend/app/src/models/category_transfer.rs @@ -0,0 +1,11 @@ +use diesel::prelude::*; + +#[derive(Queryable, Identifiable, Selectable, Debug, PartialEq)] +#[diesel(table_name = crate::schema::category_transfers)] +pub struct CategoryTransfer { + pub id: i32, + pub description: String, + pub quantity: i32, + pub from_category_id: i32, + pub to_category_id: i32, +} diff --git a/backend/app/src/models/date_time_utc.rs b/backend/app/src/models/date_time_utc.rs new file mode 100644 index 0000000..03d5802 --- /dev/null +++ b/backend/app/src/models/date_time_utc.rs @@ -0,0 +1,131 @@ +use chrono::{DateTime, Datelike, Utc}; +use diesel::{ + backend::Backend, + deserialize::{self, FromSql, FromSqlRow}, + expression::AsExpression, + serialize::{self, ToSql}, + sql_types::Text, + sqlite::Sqlite, +}; +use serde::{Deserialize, Serialize}; + +#[derive( + Eq, + PartialEq, + Ord, + PartialOrd, + Serialize, + Deserialize, + Copy, + Clone, + Debug, + AsExpression, + FromSqlRow, +)] +#[diesel(sql_type=Text)] +pub struct DateTimeUtc(DateTime); + +impl DateTimeUtc { + pub fn day_diff(&self, other: &DateTimeUtc) -> u32 { + self.0 + .num_days_from_ce() + .abs_diff(other.0.num_days_from_ce()) + } + + pub fn to_day_str(&self) -> String { + self.0 + .to_rfc3339() + .split_at_checked(10) + .map(|(day_str, _)| String::from(day_str)) + .unwrap_or_else(|| String::from("")) + } + + pub fn new(date_time_utc: DateTime) -> Self { + Self(date_time_utc) + } + + pub fn now() -> Self { + Self(Utc::now()) + } +} + +impl ToSql for DateTimeUtc { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> serialize::Result { + out.set_value(self.to_day_str()); + Ok(diesel::serialize::IsNull::No) + } +} + +impl FromSql for DateTimeUtc { + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let str: String = FromSql::::from_sql(bytes)?; + let date_time = str.as_str().parse::>()?; + Ok(DateTimeUtc(date_time)) + } +} + +#[cfg(test)] +mod test { + use chrono::TimeZone; + + use super::*; + + #[test] + fn given_midnight_when_day_diff_next_midnight_less_1s_then_return_0() { + let start = DateTimeUtc::new(Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap().to_utc()); + let end = DateTimeUtc::new( + Utc.with_ymd_and_hms(2000, 1, 1, 23, 59, 59) + .unwrap() + .to_utc(), + ); + + let day_diff = start.day_diff(&end); + let neg_day_diff = end.day_diff(&start); + + assert_eq!(day_diff, 0); + assert_eq!(day_diff, neg_day_diff); + } + + #[test] + fn given_midnight_when_day_diff_next_midnight_then_return_1() { + let start = DateTimeUtc::new(Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap().to_utc()); + let end = DateTimeUtc::new(Utc.with_ymd_and_hms(2000, 1, 2, 0, 0, 0).unwrap().to_utc()); + + let day_diff = start.day_diff(&end); + let neg_day_diff = end.day_diff(&start); + + assert_eq!(day_diff, 1); + assert_eq!(day_diff, neg_day_diff); + } + + #[test] + fn given_midnight_less_1s_when_day_diff_midnight_then_return_1() { + let start = DateTimeUtc::new( + Utc.with_ymd_and_hms(2000, 1, 1, 23, 59, 59) + .unwrap() + .to_utc(), + ); + let end = DateTimeUtc::new(Utc.with_ymd_and_hms(2000, 1, 2, 0, 0, 0).unwrap().to_utc()); + + let day_diff = start.day_diff(&end); + let neg_day_diff = end.day_diff(&start); + + assert_eq!(day_diff, 1); + assert_eq!(day_diff, neg_day_diff); + } + + #[test] + fn given_23_nov_1970_when_to_day_str_then_return_iso_day_str() { + let date_time_utc = DateTimeUtc::new( + Utc.with_ymd_and_hms(1970, 11, 23, 0, 0, 0) + .unwrap() + .to_utc(), + ); + let day_str = date_time_utc.to_day_str(); + + assert_eq!(day_str, "1970-11-23"); + } +} diff --git a/backend/app/src/models/mod.rs b/backend/app/src/models/mod.rs new file mode 100644 index 0000000..b790fcb --- /dev/null +++ b/backend/app/src/models/mod.rs @@ -0,0 +1,10 @@ +pub mod account; +pub mod budget; +pub mod budget_update; +pub mod category; +pub mod category_balance; +pub mod category_budget; +pub mod category_transfer; +pub mod date_time_utc; +pub mod transaction; +pub mod transaction_categorisation; diff --git a/backend/app/src/models/transaction.rs b/backend/app/src/models/transaction.rs new file mode 100644 index 0000000..36f33f8 --- /dev/null +++ b/backend/app/src/models/transaction.rs @@ -0,0 +1,17 @@ +use diesel::prelude::*; + +use crate::models::account::Account; + +use super::date_time_utc::DateTimeUtc; + +#[derive(Queryable, Identifiable, Selectable, Associations, Debug, PartialEq)] +#[diesel(table_name = crate::schema::transactions)] +#[diesel(belongs_to(Account))] +pub struct Transaction { + pub id: i32, + pub description: String, + pub payee: String, + pub quantity: i32, + pub date: DateTimeUtc, + pub account_id: i32, +} diff --git a/backend/app/src/models/transaction_categorisation.rs b/backend/app/src/models/transaction_categorisation.rs new file mode 100644 index 0000000..d2867f1 --- /dev/null +++ b/backend/app/src/models/transaction_categorisation.rs @@ -0,0 +1,11 @@ +use diesel::prelude::*; + +#[derive(Queryable, Identifiable, Selectable, Debug, PartialEq)] +#[diesel(table_name = crate::schema::transaction_categorisations)] +pub struct TransactionCategorisations { + pub id: i32, + pub description: String, + pub quantity: i32, + pub transaction_id: i32, + pub category_id: i32, +} diff --git a/backend/app/src/queries/accounts/get_all.rs b/backend/app/src/queries/accounts/get_all.rs new file mode 100644 index 0000000..12941ac --- /dev/null +++ b/backend/app/src/queries/accounts/get_all.rs @@ -0,0 +1,7 @@ +use diesel::{result::Error, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; + +use crate::{models::account::Account, schema::accounts::dsl::accounts}; + +pub fn get_all_accounts(connection: &mut SqliteConnection) -> Result, Error> { + accounts.select(Account::as_select()).load(connection) +} diff --git a/backend/app/src/queries/accounts/insert.rs b/backend/app/src/queries/accounts/insert.rs new file mode 100644 index 0000000..c20cf60 --- /dev/null +++ b/backend/app/src/queries/accounts/insert.rs @@ -0,0 +1,14 @@ +use diesel::prelude::*; +use diesel::result::Error; + +use crate::models::account::Account; +use crate::schema::accounts::dsl::accounts as accounts_table; + +pub fn insert_account( + accounts: &[Account], + connection: &mut SqliteConnection, +) -> Result { + diesel::insert_into(accounts_table) + .values(accounts) + .execute(connection) +} diff --git a/backend/app/src/queries/accounts/mod.rs b/backend/app/src/queries/accounts/mod.rs new file mode 100644 index 0000000..94fa360 --- /dev/null +++ b/backend/app/src/queries/accounts/mod.rs @@ -0,0 +1,5 @@ +mod get_all; +mod insert; + +pub use get_all::get_all_accounts; +pub use insert::insert_account; diff --git a/backend/app/src/queries/budget_updates/get_all.rs b/backend/app/src/queries/budget_updates/get_all.rs new file mode 100644 index 0000000..97f2f2f --- /dev/null +++ b/backend/app/src/queries/budget_updates/get_all.rs @@ -0,0 +1,13 @@ +use crate::{ + models::budget_update::BudgetUpdate, + schema::budget_updates::dsl::budget_updates as budget_updates_table, +}; +use diesel::{result::Error, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; + +pub fn get_all_budget_updates( + connection: &mut SqliteConnection, +) -> Result, Error> { + budget_updates_table + .select(BudgetUpdate::as_select()) + .load::(connection) +} diff --git a/backend/app/src/queries/budget_updates/get_first_with_date.rs b/backend/app/src/queries/budget_updates/get_first_with_date.rs new file mode 100644 index 0000000..a2824be --- /dev/null +++ b/backend/app/src/queries/budget_updates/get_first_with_date.rs @@ -0,0 +1,19 @@ +use crate::{ + models::budget_update::BudgetUpdate, + schema::budget_updates::{ + self as budget_updates_schema, dsl::budget_updates as budget_updates_table, + }, +}; +use diesel::{ + result::Error, ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection, +}; + +pub fn get_first_budget_update_with_date( + date: &str, + connection: &mut SqliteConnection, +) -> Result { + budget_updates_table + .select(BudgetUpdate::as_select()) + .filter(budget_updates_schema::date.eq(date)) + .first(connection) +} diff --git a/backend/app/src/queries/budget_updates/get_most_recent_with_category_id.rs b/backend/app/src/queries/budget_updates/get_most_recent_with_category_id.rs new file mode 100644 index 0000000..4753092 --- /dev/null +++ b/backend/app/src/queries/budget_updates/get_most_recent_with_category_id.rs @@ -0,0 +1,18 @@ +use crate::{ + models::date_time_utc::DateTimeUtc, + schema::budget_updates::{ + self as budget_updates_schema, dsl::budget_updates as budget_updates_table, + }, +}; +use diesel::{dsl::max, result::Error, ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection}; + +pub fn get_most_recent_budget_update_date_with_category_id( + category_id: i32, + connection: &mut SqliteConnection, +) -> Result, Error> { + budget_updates_table + .select(max(budget_updates_schema::date)) + .filter(budget_updates_schema::category_id.eq(category_id)) + .filter(budget_updates_schema::date.le(DateTimeUtc::now().to_day_str())) + .first(connection) +} diff --git a/backend/app/src/queries/budget_updates/mod.rs b/backend/app/src/queries/budget_updates/mod.rs new file mode 100644 index 0000000..3b514dd --- /dev/null +++ b/backend/app/src/queries/budget_updates/mod.rs @@ -0,0 +1,7 @@ +mod get_all; +mod get_first_with_date; +mod get_most_recent_with_category_id; + +pub use get_all::get_all_budget_updates; +pub use get_first_with_date::get_first_budget_update_with_date; +pub use get_most_recent_with_category_id::get_most_recent_budget_update_date_with_category_id; diff --git a/backend/app/src/queries/categories/get_all.rs b/backend/app/src/queries/categories/get_all.rs new file mode 100644 index 0000000..db5a073 --- /dev/null +++ b/backend/app/src/queries/categories/get_all.rs @@ -0,0 +1,8 @@ +use crate::{models::category::Category, schema::categories::dsl::categories as categories_table}; +use diesel::{result::Error, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; + +pub fn get_all_categories(connection: &mut SqliteConnection) -> Result, Error> { + categories_table + .select(Category::as_select()) + .load(connection) +} diff --git a/backend/app/src/queries/categories/get_all_ids.rs b/backend/app/src/queries/categories/get_all_ids.rs new file mode 100644 index 0000000..7c7c912 --- /dev/null +++ b/backend/app/src/queries/categories/get_all_ids.rs @@ -0,0 +1,8 @@ +use crate::schema::categories::{self as categories_schema, dsl::categories as categories_table}; +use diesel::{result::Error, QueryDsl, RunQueryDsl, SqliteConnection}; + +pub fn get_all_category_ids(connection: &mut SqliteConnection) -> Result, Error> { + categories_table + .select(categories_schema::id) + .load(connection) +} diff --git a/backend/app/src/queries/categories/mod.rs b/backend/app/src/queries/categories/mod.rs new file mode 100644 index 0000000..80356b3 --- /dev/null +++ b/backend/app/src/queries/categories/mod.rs @@ -0,0 +1,5 @@ +mod get_all; +mod get_all_ids; + +pub use self::get_all::get_all_categories; +pub use self::get_all_ids::get_all_category_ids; diff --git a/backend/app/src/queries/category_balances/get_all.rs b/backend/app/src/queries/category_balances/get_all.rs new file mode 100644 index 0000000..713fbeb --- /dev/null +++ b/backend/app/src/queries/category_balances/get_all.rs @@ -0,0 +1,43 @@ +use crate::{ + models::category_balance::CategoryBalance, + queries::{ + budget_updates::get_all_budget_updates, + categories::get_all_categories, + 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, +}; +use diesel::{result::Error, SqliteConnection}; + +pub fn get_all_category_balances( + connection: &mut SqliteConnection, +) -> Result, Error> { + 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::::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) +} diff --git a/backend/app/src/queries/category_balances/mod.rs b/backend/app/src/queries/category_balances/mod.rs new file mode 100644 index 0000000..3fc1be2 --- /dev/null +++ b/backend/app/src/queries/category_balances/mod.rs @@ -0,0 +1,3 @@ +mod get_all; + +pub use get_all::get_all_category_balances; diff --git a/backend/app/src/queries/category_budgets/get_most_recent_per_category_id.rs b/backend/app/src/queries/category_budgets/get_most_recent_per_category_id.rs new file mode 100644 index 0000000..6b76efd --- /dev/null +++ b/backend/app/src/queries/category_budgets/get_most_recent_per_category_id.rs @@ -0,0 +1,42 @@ +use crate::{ + models::{budget::Budget, category_budget::CategoryBudget}, + queries::{ + budget_updates::{ + get_first_budget_update_with_date, get_most_recent_budget_update_date_with_category_id, + }, + categories::get_all_category_ids, + }, +}; +use diesel::{result::Error, SqliteConnection}; + +pub fn get_most_recent_category_budget_per_category_id( + connection: &mut SqliteConnection, +) -> Result, Error> { + let category_ids: Vec = get_all_category_ids(connection)?; + + let mut budgets = Vec::::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)?; + + if most_recent_budget_update_date.is_none() { + budgets.push(CategoryBudget { + category_id, + budget: Option::None, + }); + continue; + } + + let most_recent_budget_update = get_first_budget_update_with_date( + most_recent_budget_update_date.unwrap().as_str(), + connection, + )?; + + budgets.push(CategoryBudget { + category_id, + budget: Some(Budget::from(&most_recent_budget_update)), + }); + } + + Ok(budgets) +} diff --git a/backend/app/src/queries/category_budgets/mod.rs b/backend/app/src/queries/category_budgets/mod.rs new file mode 100644 index 0000000..29fc246 --- /dev/null +++ b/backend/app/src/queries/category_budgets/mod.rs @@ -0,0 +1,3 @@ +mod get_most_recent_per_category_id; + +pub use self::get_most_recent_per_category_id::get_most_recent_category_budget_per_category_id; diff --git a/backend/app/src/queries/category_transfers/mod.rs b/backend/app/src/queries/category_transfers/mod.rs new file mode 100644 index 0000000..85eb1c8 --- /dev/null +++ b/backend/app/src/queries/category_transfers/mod.rs @@ -0,0 +1,5 @@ +mod sum_quantity_per_from_category_id; +mod sum_quantity_per_to_category_id; + +pub use sum_quantity_per_from_category_id::sum_category_transfer_quantity_per_from_category_id; +pub use sum_quantity_per_to_category_id::sum_category_transfer_quantity_per_to_category_id; diff --git a/backend/app/src/queries/category_transfers/sum_quantity_per_from_category_id.rs b/backend/app/src/queries/category_transfers/sum_quantity_per_from_category_id.rs new file mode 100644 index 0000000..ae3a51a --- /dev/null +++ b/backend/app/src/queries/category_transfers/sum_quantity_per_from_category_id.rs @@ -0,0 +1,17 @@ +use diesel::{dsl::sum, result::Error, QueryDsl, RunQueryDsl, SqliteConnection}; + +use crate::schema::category_transfers::{ + self as category_transfers_schema, dsl::category_transfers as category_transfers_table, +}; + +pub fn sum_category_transfer_quantity_per_from_category_id( + connection: &mut SqliteConnection, +) -> Result)>, Error> { + 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)>(connection) +} diff --git a/backend/app/src/queries/category_transfers/sum_quantity_per_to_category_id.rs b/backend/app/src/queries/category_transfers/sum_quantity_per_to_category_id.rs new file mode 100644 index 0000000..fe6f3a7 --- /dev/null +++ b/backend/app/src/queries/category_transfers/sum_quantity_per_to_category_id.rs @@ -0,0 +1,17 @@ +use diesel::{dsl::sum, result::Error, QueryDsl, RunQueryDsl, SqliteConnection}; + +use crate::schema::category_transfers::{ + self as category_transfers_schema, dsl::category_transfers as category_transfers_table, +}; + +pub fn sum_category_transfer_quantity_per_to_category_id( + connection: &mut SqliteConnection, +) -> Result)>, Error> { + 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)>(connection) +} diff --git a/backend/app/src/queries/mod.rs b/backend/app/src/queries/mod.rs new file mode 100644 index 0000000..43328e2 --- /dev/null +++ b/backend/app/src/queries/mod.rs @@ -0,0 +1,7 @@ +pub mod accounts; +pub mod budget_updates; +pub mod categories; +pub mod category_balances; +pub mod category_budgets; +pub mod category_transfers; +pub mod transaction_categorisations; diff --git a/backend/app/src/queries/transaction_categorisations/mod.rs b/backend/app/src/queries/transaction_categorisations/mod.rs new file mode 100644 index 0000000..d4217cc --- /dev/null +++ b/backend/app/src/queries/transaction_categorisations/mod.rs @@ -0,0 +1,3 @@ +mod sum_quantity_per_category_id; + +pub use sum_quantity_per_category_id::sum_transaction_categorisation_quantity_per_category_id; diff --git a/backend/app/src/queries/transaction_categorisations/sum_quantity_per_category_id.rs b/backend/app/src/queries/transaction_categorisations/sum_quantity_per_category_id.rs new file mode 100644 index 0000000..7f93e0c --- /dev/null +++ b/backend/app/src/queries/transaction_categorisations/sum_quantity_per_category_id.rs @@ -0,0 +1,17 @@ +use crate::schema::transaction_categorisations::{ + self as transaction_categorisations_schema, + dsl::transaction_categorisations as transaction_categorisations_table, +}; +use diesel::{dsl::sum, result::Error, QueryDsl, RunQueryDsl, SqliteConnection}; + +pub fn sum_transaction_categorisation_quantity_per_category_id( + connection: &mut SqliteConnection, +) -> Result)>, Error> { + transaction_categorisations_table + .group_by(transaction_categorisations_schema::category_id) + .select(( + transaction_categorisations_schema::category_id, + sum(transaction_categorisations_schema::quantity), + )) + .load::<(i32, Option)>(connection) +} diff --git a/backend/app/src/queries/transaction_categorisations/sum_quantity_per_to_category_id.rs b/backend/app/src/queries/transaction_categorisations/sum_quantity_per_to_category_id.rs new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/src/routes/account.rs b/backend/app/src/routes/account.rs new file mode 100644 index 0000000..7bbdb4f --- /dev/null +++ b/backend/app/src/routes/account.rs @@ -0,0 +1,57 @@ +use diesel::result::DatabaseErrorKind; +use rocket::http::Status; +use rocket::serde::json::Json; +use rocket::{get, post, Responder}; + +use crate::databases::UserDataDb; +use crate::models::account::Account; +use crate::queries::accounts::{get_all_accounts as get_all_accounts_query, insert_account}; + +#[get("/account")] +pub async fn get_all_accounts(user_data_db: UserDataDb) -> Result>, Status> { + user_data_db + .run(get_all_accounts_query) + .await + .map(Json) + .map_err(|_| Status { code: 500 }) +} + +#[derive(Responder)] +pub enum CreateAccountResponder { + #[response(status = 500, content_type = "text")] + InternalServerError(String), + + #[response(status = 202, content_type = "json")] + Created(Json), + + #[response(status = 400, content_type = "text")] + BadRequest(String), +} + +#[post("/account", data = "", format = "json")] +pub async fn create_account( + account: Json, + user_data_db: UserDataDb, +) -> CreateAccountResponder { + let account = account.into_inner(); + let accounts_vec = vec![account.clone()]; + + let num_rows_inserted = user_data_db + .run(move |connection| insert_account(&accounts_vec, connection)) + .await; + + match num_rows_inserted { + Ok(_) => CreateAccountResponder::Created(Json(account)), + Err(err) => match err { + diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, info) => { + let column = info.column_name().unwrap_or("account"); + CreateAccountResponder::BadRequest(format!( + "Error creating account: {column} was not unique." + )) + } + _ => CreateAccountResponder::InternalServerError(format!( + "Error creating account: {err}" + )), + }, + } +} diff --git a/backend/app/src/routes/balance.rs b/backend/app/src/routes/balance.rs new file mode 100644 index 0000000..f423c90 --- /dev/null +++ b/backend/app/src/routes/balance.rs @@ -0,0 +1,17 @@ +use rocket::{get, http::Status, serde::json::Json}; + +use crate::{ + databases::UserDataDb, models::category_balance::CategoryBalance, + queries::category_balances::get_all_category_balances as get_all_category_balances_query, +}; + +#[get("/balance/category")] +pub async fn get_all_category_balances( + user_data_db: UserDataDb, +) -> Result>, Status> { + user_data_db + .run(get_all_category_balances_query) + .await + .map(Json) + .map_err(|_| Status { code: 500 }) +} diff --git a/backend/app/src/routes/budget.rs b/backend/app/src/routes/budget.rs new file mode 100644 index 0000000..67aae8e --- /dev/null +++ b/backend/app/src/routes/budget.rs @@ -0,0 +1,18 @@ +use rocket::get; +use rocket::http::Status; +use rocket::serde::json::Json; + +use crate::databases::UserDataDb; +use crate::models::category_budget::CategoryBudget; +use crate::queries; + +#[get("/budget/category?current")] +pub async fn get_current_category_budget_per_category( + user_data_db: UserDataDb, +) -> Result>, Status> { + user_data_db + .run(queries::category_budgets::get_most_recent_category_budget_per_category_id) + .await + .map(Json) + .map_err(|_| Status { code: 500 }) +} diff --git a/backend/app/src/routes/budget_update.rs b/backend/app/src/routes/budget_update.rs new file mode 100644 index 0000000..7732c7b --- /dev/null +++ b/backend/app/src/routes/budget_update.rs @@ -0,0 +1,18 @@ +use rocket::get; +use rocket::http::Status; +use rocket::serde::json::Json; + +use crate::databases::UserDataDb; +use crate::models::budget_update::BudgetUpdate; +use crate::queries; + +#[get("/budget-update")] +pub async fn get_all_budget_updates( + user_data_db: UserDataDb, +) -> Result>, Status> { + user_data_db + .run(queries::budget_updates::get_all_budget_updates) + .await + .map(Json) + .map_err(|_| Status { code: 500 }) +} diff --git a/backend/app/src/routes/category.rs b/backend/app/src/routes/category.rs new file mode 100644 index 0000000..01fd8d1 --- /dev/null +++ b/backend/app/src/routes/category.rs @@ -0,0 +1,16 @@ +use rocket::get; +use rocket::http::Status; +use rocket::serde::json::Json; + +use crate::databases::UserDataDb; +use crate::models::category::Category; +use crate::queries::categories::get_all_categories as get_all_categories_query; + +#[get("/category")] +pub async fn get_all_categories(user_data_db: UserDataDb) -> Result>, Status> { + user_data_db + .run(get_all_categories_query) + .await + .map(Json) + .map_err(|_| Status { code: 500 }) +} diff --git a/backend/app/src/routes/mod.rs b/backend/app/src/routes/mod.rs new file mode 100644 index 0000000..bf0ab4a --- /dev/null +++ b/backend/app/src/routes/mod.rs @@ -0,0 +1,5 @@ +pub mod account; +pub mod balance; +pub mod budget; +pub mod budget_update; +pub mod category; diff --git a/backend/app/src/schema.rs b/backend/app/src/schema.rs new file mode 100644 index 0000000..372f8f3 --- /dev/null +++ b/backend/app/src/schema.rs @@ -0,0 +1,72 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + accounts (id) { + id -> Integer, + name -> Text, + opening_balance -> Integer, + opening_date -> Text, + } +} + +diesel::table! { + budget_updates (id) { + id -> Integer, + category_id -> Integer, + date -> Text, + new_budget -> Integer, + new_period -> Integer, + } +} + +diesel::table! { + categories (id) { + id -> Integer, + name -> Text, + } +} + +diesel::table! { + category_transfers (id) { + id -> Integer, + description -> Text, + quantity -> Integer, + from_category_id -> Integer, + to_category_id -> Integer, + } +} + +diesel::table! { + transaction_categorisations (id) { + id -> Integer, + description -> Text, + quantity -> Integer, + transaction_id -> Integer, + category_id -> Integer, + } +} + +diesel::table! { + transactions (id) { + id -> Integer, + description -> Text, + payee -> Text, + quantity -> Integer, + date -> Text, + account_id -> Integer, + } +} + +diesel::joinable!(budget_updates -> categories (category_id)); +diesel::joinable!(transaction_categorisations -> categories (category_id)); +diesel::joinable!(transaction_categorisations -> transactions (transaction_id)); +diesel::joinable!(transactions -> accounts (account_id)); + +diesel::allow_tables_to_appear_in_same_query!( + accounts, + budget_updates, + categories, + category_transfers, + transaction_categorisations, + transactions, +); diff --git a/backend/app/src/utils/calculate_budgets_accrual.rs b/backend/app/src/utils/calculate_budgets_accrual.rs new file mode 100644 index 0000000..f14260d --- /dev/null +++ b/backend/app/src/utils/calculate_budgets_accrual.rs @@ -0,0 +1,159 @@ +use crate::models::{budget_update::BudgetUpdate, category::Category, date_time_utc::DateTimeUtc}; + +pub fn calculate_budgets_accrual(category: &Category, budget_updates: &[BudgetUpdate]) -> i64 { + let mut budget_updates = budget_updates + .iter() + .filter(|bu| bu.category_id == category.id) + .collect::>(); + budget_updates.sort_by_key(|bu| bu.date); + budget_updates.reverse(); + + let mut budgets_accrual = 0.0_f64; + let mut calculated_back_to_date = DateTimeUtc::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 { + continue; + } + 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; + } + + budgets_accrual.floor() as i64 +} + +#[cfg(test)] +mod test { + use chrono::{Days, Timelike, Utc}; + + use crate::models::{ + budget_update::BudgetUpdate, category::Category, date_time_utc::DateTimeUtc, + }; + + 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_eq!(result, 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: DateTimeUtc::now(), + new_budget: 100, + new_period: 1, + }]; + + let result = calculate_budgets_accrual(&category, &budget_updates); + + assert_eq!(result, 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: DateTimeUtc::new(Utc::now().checked_sub_days(Days::new(120)).unwrap()), + new_budget: 100, + new_period: 1, + }]; + + let result = calculate_budgets_accrual(&category, &budget_updates); + + assert_eq!(result, 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: DateTimeUtc::new(Utc::now().checked_sub_days(Days::new(60)).unwrap()), + new_budget: 200, + new_period: 1, + }, + BudgetUpdate { + id: 0, + category_id: 0, + date: DateTimeUtc::new(Utc::now().checked_sub_days(Days::new(120)).unwrap()), + new_budget: 100, + new_period: 1, + }, + ]; + + let result = calculate_budgets_accrual(&category, &budget_updates); + + assert_eq!(result, 18_000); + } + + #[test] + fn when_two_budget_updates_on_same_day_then_uses_latest_only() { + let category = Category { + id: 0, + name: String::from("Groceries"), + }; + let budget_updates = [ + BudgetUpdate { + id: 0, + category_id: 0, + date: DateTimeUtc::new( + Utc::now() + .checked_sub_days(Days::new(120)) + .unwrap() + .with_hour(13) + .unwrap(), + ), + new_budget: 200, + new_period: 1, + }, + BudgetUpdate { + id: 0, + category_id: 0, + date: DateTimeUtc::new( + Utc::now() + .checked_sub_days(Days::new(120)) + .unwrap() + .with_hour(12) + .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_eq!(result_leftways, 24_000); + assert_eq!(result_rightways, 24_000); + } +} diff --git a/backend/app/src/utils/calculate_category_balance.rs b/backend/app/src/utils/calculate_category_balance.rs new file mode 100644 index 0000000..3c1c62f --- /dev/null +++ b/backend/app/src/utils/calculate_category_balance.rs @@ -0,0 +1,141 @@ +use crate::models::{budget_update::BudgetUpdate, category::Category}; + +use super::{calculate_budgets_accrual, find_by_id_or}; + +pub fn calculate_category_balance( + category: &Category, + transaction_sums: &[(i32, Option)], + category_transfer_from_sums: &[(i32, Option)], + category_transfer_to_sums: &[(i32, Option)], + budget_updates: &[BudgetUpdate], +) -> 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); + + transaction_sum + budget_accruals_sum + category_transfer_to_sum - category_transfer_from_sum +} + +#[cfg(test)] +mod test { + use chrono::{Days, Utc}; + + use crate::{ + models::{budget_update::BudgetUpdate, category::Category, date_time_utc::DateTimeUtc}, + utils::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_eq!(result, 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, Some(100))]; + let category_transfer_from_sums = [(1, Some(91))]; + let category_transfer_to_sums = [(2, Some(79))]; + let budget_updates = [BudgetUpdate { + id: 0, + category_id: 1, + date: DateTimeUtc::new(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_eq!(result, 0_i64); + } + + #[test] + fn when_transaction_sum_is_none_then_returns_zero() { + let category = Category { + id: 0, + name: String::new(), + }; + let transaction_sums = [(0, None)]; + 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_eq!(result, 0_i64); + } + + #[test] + fn when_category_transfers_are_none_then_returns_zero() { + let category = Category { + id: 0, + name: String::new(), + }; + let transaction_sums = []; + let category_transfer_from_sums = [(0, None)]; + let category_transfer_to_sums = [(0, None)]; + let budget_updates = []; + + let result = calculate_category_balance( + &category, + &transaction_sums, + &category_transfer_from_sums, + &category_transfer_to_sums, + &budget_updates, + ); + + assert_eq!(result, 0_i64); + } + + #[test] + fn when_some_data_then_calculates_as_expected() { + let category = Category { + id: 0, + name: String::new(), + }; + let transaction_sums = [(0, Some(100))]; + let category_transfer_from_sums = [(0, Some(91))]; + let category_transfer_to_sums = [(0, Some(79))]; + let budget_updates = [BudgetUpdate { + id: 0, + category_id: 0, + date: DateTimeUtc::new(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_eq!(result, expected_result); + } +} diff --git a/backend/app/src/utils/find_by_id_or.rs b/backend/app/src/utils/find_by_id_or.rs new file mode 100644 index 0000000..f5add30 --- /dev/null +++ b/backend/app/src/utils/find_by_id_or.rs @@ -0,0 +1,53 @@ +pub fn find_by_id_or( + arr: &[(Id, Option)], + id: Id, + default: Val, +) -> Val { + arr.iter() + .find(|(elem_id, _)| *elem_id == id) + .map_or(default, |row| match row { + (_, Some(quantity)) => *quantity, + (_, _) => default, + }) +} + +#[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, Some(100)), (2, Some(200))]; + + let result = find_by_id_or(&arr, 1, 42); + + assert_eq!(result, 42); + } + + #[test] + fn when_value_is_none_then_return_default() { + let arr = [(1, None)]; + + let result = find_by_id_or(&arr, 1, 42); + + assert_eq!(result, 42); + } + + #[test] + fn when_value_is_some_then_return_value() { + let arr = [(1, Some(67))]; + + let result = find_by_id_or(&arr, 1, 42); + + assert_eq!(result, 67); + } +} diff --git a/backend/app/src/utils/mod.rs b/backend/app/src/utils/mod.rs new file mode 100644 index 0000000..84a27af --- /dev/null +++ b/backend/app/src/utils/mod.rs @@ -0,0 +1,7 @@ +mod calculate_budgets_accrual; +mod calculate_category_balance; +mod find_by_id_or; + +pub use calculate_budgets_accrual::calculate_budgets_accrual; +pub use calculate_category_balance::calculate_category_balance; +pub use find_by_id_or::find_by_id_or; -- cgit v1.2.3