diff options
| author | joeac <me@joeac.net> | 2026-01-17 17:17:50 +0000 |
|---|---|---|
| committer | joeac <me@joeac.net> | 2026-01-17 17:17:50 +0000 |
| commit | bf74f305894bde98033932235a4210c7d6865adf (patch) | |
| tree | 051422c97fcb5d7c2eafd426ea66e1a906948747 /transformers | |
| parent | fe85652e4d3e8906824baddaafd05d19105579b8 (diff) | |
| parent | f69ec8416c4e6301dcfa06791b025785d7f61b6b (diff) | |
Merge pull request 'random-stuff' (#4) from random-stuff into main
Reviewed-on: https://git.joeac.net/joeac/schist/pulls/4
Diffstat (limited to 'transformers')
19 files changed, 997 insertions, 514 deletions
diff --git a/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_date.rs b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_date.rs index 13525fc..d8b6e87 100644 --- a/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_date.rs +++ b/transformers/actualbudget_transformer/actualbudget_models/src/actualbudget_date.rs @@ -1,8 +1,14 @@ -use diesel::{backend::Backend, deserialize::{FromSql, FromSqlRow}, expression::AsExpression, sql_types::Integer, sqlite::Sqlite}; +use diesel::{ + backend::Backend, + deserialize::{FromSql, FromSqlRow}, + expression::AsExpression, + sql_types::Integer, + sqlite::Sqlite, +}; -#[derive(Clone, Hash, PartialEq, Eq, Debug, AsExpression, FromSqlRow)] +#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug, AsExpression, FromSqlRow)] #[diesel(sql_type=Integer)] -pub struct ActualbudgetDate(i32); +pub struct ActualbudgetDate(i64); #[derive(Clone, PartialOrd, PartialEq, Eq)] enum DatePart { @@ -17,7 +23,12 @@ enum DatePart { impl ActualbudgetDate { pub fn to_iso(&self) -> String { - format!("{0}-{1}-{2}T00:00:00.000Z", self.year(), self.month(), self.day()) + format!( + "{0}-{1}-{2}T00:00:00.000Z", + self.year(), + self.month(), + self.day() + ) } pub fn to_iso_date(&self) -> String { @@ -40,7 +51,20 @@ impl ActualbudgetDate { } pub fn from_i32(i: i32) -> Self { - ActualbudgetDate(i) + ActualbudgetDate(i.into()) + } + + pub fn from_ymd<T, S, U>(year: T, month: S, day: U) -> Self + where + T: Into<i64>, + S: Into<i64>, + U: Into<i64>, + { + Self(year.into() * 10_000 + month.into() * 100 + day.into()) + } + + pub fn oldest_valid_date() -> Self { + Self(10000101) } fn date_part(&self) -> DatePart { @@ -51,7 +75,7 @@ impl ActualbudgetDate { } } - fn dayified_value(&self) -> i32 { + fn dayified_value(&self) -> i64 { match self.date_part() { DatePart::DayOfMonth => self.0, DatePart::MonthOfYear => self.0 * 100 + 1, @@ -74,6 +98,19 @@ impl PartialOrd for ActualbudgetDate { impl FromSql<Integer, Sqlite> for ActualbudgetDate { fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> { let int: i32 = FromSql::<Integer, Sqlite>::from_sql(bytes)?; - Ok(ActualbudgetDate(int)) + Ok(ActualbudgetDate(int.into())) + } +} + +#[cfg(test)] +mod test { + use crate::ActualbudgetDate; + + #[test] + fn when_from_ymd_2026_01_16_then_constructs_2026_01_16() { + let actualbudget_date = ActualbudgetDate::from_ymd(2026, 1, 16); + assert_eq!(actualbudget_date.year(), "2026"); + assert_eq!(actualbudget_date.month(), "01"); + assert_eq!(actualbudget_date.day(), "16"); } } diff --git a/transformers/actualbudget_transformer/actualbudget_queries/src/actualbudget_accounts.rs b/transformers/actualbudget_transformer/actualbudget_queries/src/actualbudget_accounts.rs index d499208..8a590a1 100644 --- a/transformers/actualbudget_transformer/actualbudget_queries/src/actualbudget_accounts.rs +++ b/transformers/actualbudget_transformer/actualbudget_queries/src/actualbudget_accounts.rs @@ -1,25 +1,14 @@ use actualbudget_models::ActualbudgetAccount; -use actualbudget_schema::actualbudget_schema::accounts::{ - self as accounts_schema, - dsl::accounts as accounts_table, -}; +use actualbudget_schema::actualbudget_schema::accounts::dsl::accounts as accounts_table; use anyhow::{Context, Result}; -use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; +use diesel::{QueryDsl, RunQueryDsl, SelectableHelper, SqliteConnection}; -pub fn get_all_actualbudget_accounts(connection: &mut SqliteConnection) -> Result<Vec<ActualbudgetAccount>> { +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/transformers/actualbudget_transformer/actualbudget_queries/tests/actualbudget_accounts.rs b/transformers/actualbudget_transformer/actualbudget_queries/tests/actualbudget_accounts.rs index 954cac1..78953f2 100644 --- a/transformers/actualbudget_transformer/actualbudget_queries/tests/actualbudget_accounts.rs +++ b/transformers/actualbudget_transformer/actualbudget_queries/tests/actualbudget_accounts.rs @@ -1,6 +1,6 @@ pub mod common; -use actualbudget_queries::actualbudget_accounts::{get_all_actualbudget_accounts, get_category_transfers_account}; +use actualbudget_queries::actualbudget_accounts::get_all_actualbudget_accounts; use common::{account_names::ACCOUNT_NAMES, db_url::DB_URL}; use diesel::{Connection, SqliteConnection}; @@ -17,16 +17,3 @@ fn given_test_database_when_get_all_accounts_then_return_accounts() { 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/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/actualbudget_state.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/actualbudget_state.rs index cda63b1..22c1eef 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/actualbudget_state.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/actualbudget_state.rs @@ -1,12 +1,18 @@ use std::cmp::min; use actualbudget_models::{ - ActualbudgetAccount, ActualbudgetCategory, ActualbudgetCategoryGroup, ActualbudgetDate, ActualbudgetPayee, ActualbudgetTransaction, ActualbudgetZeroBudget + ActualbudgetAccount, ActualbudgetCategory, ActualbudgetCategoryGroup, ActualbudgetDate, + ActualbudgetPayee, ActualbudgetTransaction, ActualbudgetZeroBudget, }; use actualbudget_queries::{ - actualbudget_accounts::get_all_actualbudget_accounts, actualbudget_categories::get_all_actualbudget_categories, actualbudget_category_groups::get_all_actualbudget_category_groups, actualbudget_payees::get_all_actualbudget_payees, actualbudget_transactions::{ + actualbudget_accounts::get_all_actualbudget_accounts, + actualbudget_categories::get_all_actualbudget_categories, + actualbudget_category_groups::get_all_actualbudget_category_groups, + actualbudget_payees::get_all_actualbudget_payees, + actualbudget_transactions::{ get_all_actualbudget_transactions, get_first_actualbudget_transaction_date, - }, actualbudget_zero_budgets::{get_all_actualbudget_zero_budgets, get_first_zero_budget_date} + }, + actualbudget_zero_budgets::{get_all_actualbudget_zero_budgets, get_first_zero_budget_date}, }; use anyhow::Result; use diesel::SqliteConnection; @@ -31,7 +37,9 @@ pub fn read_actualbudget_state( ), actualbudget_accounts: get_all_actualbudget_accounts(actualbudget_connection)?, actualbudget_categories: get_all_actualbudget_categories(actualbudget_connection)?, - actualbudget_category_groups: get_all_actualbudget_category_groups(actualbudget_connection)?, + actualbudget_category_groups: get_all_actualbudget_category_groups( + actualbudget_connection, + )?, actualbudget_payees: get_all_actualbudget_payees(actualbudget_connection)?, actualbudget_zero_budgets: get_all_actualbudget_zero_budgets(actualbudget_connection)?, actualbudget_transactions: get_all_actualbudget_transactions(actualbudget_connection)?, diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/schist_state.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/schist_state.rs index ca5a15c..f42fd16 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/schist_state.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/schist_state.rs @@ -1,7 +1,7 @@ use anyhow::Result; use diesel::SqliteConnection; use schist_models::{ - Account, AccountTransfer, Bucket, BucketTransfer, DateUtc, Drip, Pipe, Transaction, + Account, AccountTransfer, Bucket, BucketTransaction, DateUtc, Drip, Pipe, Transaction, }; pub struct SchistState { @@ -12,7 +12,7 @@ pub struct SchistState { pub budget_drips: Vec<Drip>, pub transactions: Vec<Transaction>, pub pipes: Vec<Pipe>, - pub bucket_transfers: Vec<BucketTransfer>, + pub bucket_transactions: Vec<BucketTransaction>, } pub fn export_schist_state( @@ -32,8 +32,8 @@ pub fn export_schist_state( schist_connection, )?; schist_queries::pipes::insert_pipes(&schist_state.pipes, schist_connection)?; - schist_queries::bucket_transfers::insert_bucket_transfers( - &schist_state.bucket_transfers, + schist_queries::bucket_transactions::insert_bucket_transactions( + &schist_state.bucket_transactions, schist_connection, )?; Ok(()) diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state.rs index a116649..75aa5bb 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state.rs @@ -1,27 +1,30 @@ mod is_account_transfer; -mod is_bucket_transfer; +mod is_category_transfer; mod transform_account_transfers; mod transform_accounts; +mod transform_bucket_transactions_from_budgets_not_carried_over; +mod transform_bucket_transactions_from_category_transfers; mod transform_categories_to_buckets; mod transform_date; mod transform_id; mod transform_state; mod transform_transactions; -mod transform_transactions_to_bucket_transfers; mod transform_zero_budgets_to_drips; mod transform_zero_budgets_to_pipes; use is_account_transfer::is_account_transfer; use is_account_transfer::is_not_account_transfer; -use is_bucket_transfer::is_bucket_transfer; -use is_bucket_transfer::is_not_bucket_transfer; +use is_category_transfer::is_category_transfer; +use is_category_transfer::is_not_category_transfer; use transform_account_transfers::transform_account_transfers; use transform_accounts::transform_accounts; +use transform_bucket_transactions_from_budgets_not_carried_over::transform_bucket_transactions_from_budgets_not_carried_over; +use transform_bucket_transactions_from_category_transfers::transform_bucket_transactions_from_category_transfers; use transform_categories_to_buckets::transform_categories_to_buckets; use transform_date::transform_date; use transform_id::transform_id; use transform_transactions::transform_transactions; -use transform_transactions_to_bucket_transfers::transform_transactions_to_bucket_transfers; +use transform_zero_budgets_to_drips::transform_zero_budgets_to_drips; use transform_zero_budgets_to_pipes::transform_zero_budgets_to_pipes; pub use transform_state::transform_state; diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_bucket_transfer.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs index 188697e..75d48b7 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_bucket_transfer.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs @@ -1,14 +1,14 @@ use actualbudget_models::ActualbudgetTransaction; -pub fn is_bucket_transfer( +pub fn is_category_transfer( transaction: &ActualbudgetTransaction, transactions: &[ActualbudgetTransaction], ) -> bool { - is_parent_bucket_transfer(transaction, transactions) - || is_child_bucket_transfer(transaction, transactions) + is_parent_category_transfer(transaction, transactions) + || is_child_category_transfer(transaction, transactions) } -fn is_parent_bucket_transfer( +fn is_parent_category_transfer( transaction: &ActualbudgetTransaction, transactions: &[ActualbudgetTransaction], ) -> bool { @@ -24,7 +24,7 @@ fn is_parent_bucket_transfer( sum_of_children_amounts == 0 } -fn is_child_bucket_transfer( +fn is_child_category_transfer( transaction: &ActualbudgetTransaction, transactions: &[ActualbudgetTransaction], ) -> bool { @@ -38,9 +38,9 @@ fn is_parent_and_child(parent: &ActualbudgetTransaction, child: &ActualbudgetTra parent.is_parent && child.is_child && child.parent_id.clone().is_some_and(|id| id == parent.id) } -pub fn is_not_bucket_transfer( +pub fn is_not_category_transfer( transaction: &ActualbudgetTransaction, transactions: &[ActualbudgetTransaction], ) -> bool { - !is_bucket_transfer(transaction, transactions) + !is_category_transfer(transaction, transactions) } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs index 76eaf0c..13f63c0 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs @@ -1,7 +1,7 @@ use actualbudget_models::{ActualbudgetAccount, ActualbudgetTransaction}; use schist_models::{Account, DateUtc}; -use super::{is_bucket_transfer, transform_id}; +use super::{is_category_transfer, transform_id}; pub fn transform_accounts( actualbudget_accounts: &[ActualbudgetAccount], @@ -22,7 +22,7 @@ fn is_empty_account( actualbudget_transactions.iter().all(|t| { t.is_starting_balance() && t.amount == 0 || t.account_id != actualbudget_account.id - || is_bucket_transfer(t, actualbudget_transactions) + || is_category_transfer(t, actualbudget_transactions) }) } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_bucket_transactions_from_budgets_not_carried_over.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_bucket_transactions_from_budgets_not_carried_over.rs new file mode 100644 index 0000000..1bdeaff --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_bucket_transactions_from_budgets_not_carried_over.rs @@ -0,0 +1,350 @@ +use std::collections::HashMap; + +use actualbudget_models::{ActualbudgetDate, ActualbudgetTransaction, ActualbudgetZeroBudget}; +use anyhow::Result; +use itertools::Itertools; +use schist_models::BucketTransaction; +use schist_traits::Dateable; + +use crate::transform_state::{transform_date, transform_id}; + +pub fn transform_bucket_transactions_from_budgets_not_carried_over( + actualbudget_zero_budgets: &[ActualbudgetZeroBudget], + actualbudget_transactions: &[ActualbudgetTransaction], +) -> Result<Vec<BucketTransaction>> { + let budgets_not_carried_over: Vec<&ActualbudgetZeroBudget> = actualbudget_zero_budgets + .iter() + .filter(|zb| !zb.do_carry_over) + .sorted_unstable_by_key(|zb| zb.month) + .collect(); + let mut last_reset_to_zero_per_category_id: HashMap<String, ActualbudgetDate> = + HashMap::from_iter( + budgets_not_carried_over + .iter() + .unique_by(|zb| zb.category_id.clone()) + .map(|zb| { + ( + zb.category_id.clone(), + ActualbudgetDate::oldest_valid_date(), + ) + }), + ); + + let mut bucket_transactions = Vec::new(); + for budget in budgets_not_carried_over { + let last_not_carried_over_month_start: ActualbudgetDate = + last_reset_to_zero_per_category_id + .get(&budget.category_id) + .cloned() + .unwrap_or_else(|| ActualbudgetDate::oldest_valid_date()); + let start_of_next_month = transform_date(&budget.month)?.first_day_in_next_month(); + let actualbudget_start_of_next_month = ActualbudgetDate::from_ymd( + start_of_next_month.year(), + start_of_next_month.month(), + start_of_next_month.day(), + ); + + let balance_from_zero_budgets: i32 = actualbudget_zero_budgets + .iter() + .filter(|zb| { + zb.category_id == budget.category_id + && zb.month >= last_not_carried_over_month_start + && zb.month < actualbudget_start_of_next_month + }) + .map(|zb| zb.amount) + .sum(); + let balance_from_transactions: i32 = actualbudget_transactions + .iter() + .filter(|t| { + t.category_id + .as_ref() + .is_some_and(|id| *id == budget.category_id) + && t.date >= last_not_carried_over_month_start + && t.date < actualbudget_start_of_next_month + }) + .map(|t| t.amount) + .sum(); + let balance_at_end_of_month = balance_from_zero_budgets + balance_from_transactions; + + if balance_at_end_of_month < 0 { + last_reset_to_zero_per_category_id + .insert(budget.category_id.clone(), actualbudget_start_of_next_month); + bucket_transactions.push(BucketTransaction { + id: transform_id(&budget.id), + description: format!("Actualbudget budget <{}> not carried over", budget.id), + date: start_of_next_month, + amount: -balance_at_end_of_month, + bucket_id: transform_id(&budget.category_id), + }); + } + } + + Ok(bucket_transactions) +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::{ + actualbudget_transaction::make_fake_actualbudget_transaction_builder, + actualbudget_zero_budget::make_fake_actualbudget_zero_budget_builder, + }; + use actualbudget_models::ActualbudgetDate; + use schist_models::{BucketTransaction, DateUtc}; + + use crate::transform_state::{ + transform_bucket_transactions_from_budgets_not_carried_over::transform_bucket_transactions_from_budgets_not_carried_over, + transform_id::transform_id, + }; + + #[test] + fn no_resets() { + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("zb0") + .category_id(String::from("c0")) + .amount(100) + .do_carry_over(false) + .month(ActualbudgetDate::from_i32(202601)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("zb1") + .category_id(String::from("c0")) + .amount(100) + .do_carry_over(false) + .month(ActualbudgetDate::from_i32(202602)) + .build() + .unwrap(), + ]; + + let actualbudget_transactions = [ + make_fake_actualbudget_transaction_builder("t0") + .category_id(Some(String::from("c0"))) + .amount(-50) + .date(ActualbudgetDate::from_ymd(2026, 1, 1)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t0") + .category_id(Some(String::from("c0"))) + .amount(-50) + .date(ActualbudgetDate::from_ymd(2026, 1, 31)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t1") + .category_id(Some(String::from("c1"))) + .amount(-101) + .date(ActualbudgetDate::from_ymd(2026, 1, 21)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t1") + .category_id(Some(String::from("c1"))) + .amount(-101) + .date(ActualbudgetDate::from_ymd(2026, 2, 21)) + .build() + .unwrap(), + ]; + + let result = transform_bucket_transactions_from_budgets_not_carried_over( + &actualbudget_zero_budgets, + &actualbudget_transactions, + ); + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(Vec::<BucketTransaction>::new(), result); + } + + #[test] + fn one_reset() { + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("zb0") + .category_id(String::from("c0")) + .amount(100) + .do_carry_over(false) + .month(ActualbudgetDate::from_i32(202601)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("zb1") + .category_id(String::from("c0")) + .amount(100) + .do_carry_over(false) + .month(ActualbudgetDate::from_i32(202602)) + .build() + .unwrap(), + ]; + + let actualbudget_transactions = [ + make_fake_actualbudget_transaction_builder("t0") + .category_id(Some(String::from("c0"))) + .amount(-50) + .date(ActualbudgetDate::from_ymd(2026, 1, 1)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t0") + .category_id(Some(String::from("c0"))) + .amount(-51) + .date(ActualbudgetDate::from_ymd(2026, 1, 31)) + .build() + .unwrap(), + ]; + + let result = transform_bucket_transactions_from_budgets_not_carried_over( + &actualbudget_zero_budgets, + &actualbudget_transactions, + ); + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.len(), 1); + let transaction = result.first().unwrap(); + assert_eq!(transaction.amount, 1); + assert_eq!(transaction.bucket_id, transform_id("c0")); + assert_eq!(transaction.date, DateUtc::from_ymd(2026, 2, 1).unwrap()); + } + + #[test] + fn two_resets() { + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("zb0") + .category_id(String::from("c0")) + .amount(100) + .do_carry_over(false) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("zb1") + .category_id(String::from("c0")) + .amount(101) + .do_carry_over(false) + .month(ActualbudgetDate::from_i32(202402)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("zb2") + .category_id(String::from("c0")) + .amount(102) + .do_carry_over(false) + .month(ActualbudgetDate::from_i32(202403)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("zb3") + .category_id(String::from("c0")) + .amount(103) + .do_carry_over(false) + .month(ActualbudgetDate::from_i32(202404)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("zb4") + .category_id(String::from("c0")) + .amount(104) + .do_carry_over(false) + .month(ActualbudgetDate::from_i32(202405)) + .build() + .unwrap(), + ]; + + let actualbudget_transactions = [ + make_fake_actualbudget_transaction_builder("t0") + .category_id(Some(String::from("c0"))) + .amount(-50) + .date(ActualbudgetDate::from_ymd(2024, 1, 1)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t1") + .category_id(Some(String::from("c0"))) + .amount(-50) + .date(ActualbudgetDate::from_ymd(2024, 1, 31)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t2") + .category_id(Some(String::from("c0"))) + .amount(-50) + .date(ActualbudgetDate::from_ymd(2024, 2, 1)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t3") + .category_id(Some(String::from("c0"))) + .amount(-53) + .date(ActualbudgetDate::from_ymd(2024, 2, 29)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t4") + .category_id(Some(String::from("c0"))) + .amount(-92) + .date(ActualbudgetDate::from_ymd(2024, 3, 15)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t5") + .category_id(Some(String::from("c0"))) + .amount(-93) + .date(ActualbudgetDate::from_ymd(2024, 4, 15)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t6") + .category_id(Some(String::from("c0"))) + .amount(-127) + .date(ActualbudgetDate::from_ymd(2024, 5, 15)) + .build() + .unwrap(), + ]; + + let result = transform_bucket_transactions_from_budgets_not_carried_over( + &actualbudget_zero_budgets, + &actualbudget_transactions, + ); + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.len(), 2); + + let transaction = result.iter().find(|bt| bt.amount == 2); + assert!(transaction.is_some()); + let transaction = transaction.unwrap(); + assert_eq!(transaction.amount, 2); + assert_eq!(transaction.bucket_id, transform_id("c0")); + assert_eq!(transaction.date, DateUtc::from_ymd(2024, 3, 1).unwrap()); + + let transaction = result.iter().find(|bt| bt.amount == 3); + assert!(transaction.is_some()); + let transaction = transaction.unwrap(); + assert_eq!(transaction.bucket_id, transform_id("c0")); + assert_eq!(transaction.date, DateUtc::from_ymd(2024, 6, 1).unwrap()); + } + + #[test] + fn carried_over() { + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("zb0") + .category_id(String::from("c0")) + .amount(100) + .do_carry_over(true) + .month(ActualbudgetDate::from_i32(202601)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("zb1") + .category_id(String::from("c0")) + .amount(100) + .do_carry_over(true) + .month(ActualbudgetDate::from_i32(202602)) + .build() + .unwrap(), + ]; + + let actualbudget_transactions = [ + make_fake_actualbudget_transaction_builder("t0") + .category_id(Some(String::from("c0"))) + .amount(-50) + .date(ActualbudgetDate::from_ymd(2026, 1, 1)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("t0") + .category_id(Some(String::from("c0"))) + .amount(-51) + .date(ActualbudgetDate::from_ymd(2026, 1, 31)) + .build() + .unwrap(), + ]; + + let result = transform_bucket_transactions_from_budgets_not_carried_over( + &actualbudget_zero_budgets, + &actualbudget_transactions, + ); + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result.len(), 0); + } +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_bucket_transactions_from_category_transfers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_bucket_transactions_from_category_transfers.rs new file mode 100644 index 0000000..4dce8fa --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_bucket_transactions_from_category_transfers.rs @@ -0,0 +1,452 @@ +use actualbudget_models::ActualbudgetTransaction; +use anyhow::{bail, Context, Result}; +use schist_models::BucketTransaction; + +use crate::transform_state::transform_date; + +use super::{is_category_transfer, transform_id}; + +pub fn transform_bucket_transactions_from_category_transfers( + actualbudget_transactions: &[ActualbudgetTransaction], +) -> Result<Vec<BucketTransaction>> { + let actualbudget_category_transfers = actualbudget_transactions + .iter() + .filter(|transaction| is_category_transfer(transaction, actualbudget_transactions)); + let actualbudget_parent_category_transfers: Vec<ActualbudgetTransaction> = + actualbudget_category_transfers + .clone() + .filter(|t| t.is_parent) + .map(ActualbudgetTransaction::clone) + .collect(); + let actualbudget_child_category_transfers: Vec<ActualbudgetTransaction> = + actualbudget_category_transfers + .filter(|t| t.is_child) + .map(ActualbudgetTransaction::clone) + .collect(); + + let mut result = Vec::new(); + for pct in actualbudget_parent_category_transfers { + result.extend( + transform_category_transfer(&pct, &actualbudget_child_category_transfers) + .with_context(|| "failed to transform bucket transactions")?, + ) + } + Ok(result) +} + +fn transform_category_transfer( + actualbudget_parent_category_transfer: &ActualbudgetTransaction, + actualbudget_child_category_transfers: &[ActualbudgetTransaction], +) -> Result<Vec<BucketTransaction>> { + transform_category_transfer_without_context( + actualbudget_parent_category_transfer, + actualbudget_child_category_transfers, + ) + .with_context(|| "failed to transform category transfer") +} + +fn transform_category_transfer_without_context( + actualbudget_parent_category_transfer: &ActualbudgetTransaction, + actualbudget_child_category_transfers: &[ActualbudgetTransaction], +) -> Result<Vec<BucketTransaction>> { + let actualbudget_child_category_transfers = actualbudget_child_category_transfers + .iter() + .filter(|t| { + t.parent_id + .clone() + .is_some_and(|parent_id| parent_id == actualbudget_parent_category_transfer.id) + }) + .map(ActualbudgetTransaction::clone); + + let withdrawals: Vec<ActualbudgetTransaction> = actualbudget_child_category_transfers + .clone() + .filter(|t| t.amount < 0) + .collect(); + if withdrawals.len() != 1 { + bail!( + "actualbudget parent category transfer {} had {} withdrawals", + actualbudget_parent_category_transfer.id, + withdrawals.len(), + ); + } + let withdrawal = withdrawals.get(0).unwrap(); + println!("withdrawal: {:?}", withdrawal); + + if withdrawal.category_id.is_none() { + bail!( + "actualbudget child withdrawal {} had no category ID", + withdrawal.id + ); + } + let withdrawal_bucket_id: i32 = transform_id(&withdrawal.category_id.clone().unwrap()); + + let deposits: Vec<ActualbudgetTransaction> = actualbudget_child_category_transfers + .clone() + .filter(|t| t.amount > 0) + .collect(); + if deposits.len() != 1 { + bail!( + "actualbudget parent category transfer {} had {} deposits", + actualbudget_parent_category_transfer.id, + deposits.len(), + ); + } + let deposit = deposits.get(0).unwrap(); + println!("deposit: {:?}", deposit); + + if deposit.category_id.is_none() { + bail!( + "actualbudget child deposit {} had no category ID", + deposit.id + ); + } + let deposit_id: i32 = transform_id(&deposit.category_id.clone().unwrap()); + + if withdrawal.amount != -deposit.amount { + bail!("actualbudget category transfer amounts did not sum to zero"); + } + + let amount: i32 = deposit.amount.try_into().unwrap(); + let date = transform_date(&actualbudget_parent_category_transfer.date)?; + + Ok(vec![ + BucketTransaction { + id: transform_id(&deposit.id), + description: actualbudget_parent_category_transfer + .notes + .clone() + .unwrap_or_else(String::new), + amount: -amount, + date, + bucket_id: withdrawal_bucket_id, + }, + BucketTransaction { + id: transform_id(&withdrawal.id), + description: actualbudget_parent_category_transfer + .notes + .clone() + .unwrap_or_else(String::new), + amount, + date, + bucket_id: deposit_id, + }, + ]) +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::actualbudget_transaction::make_fake_actualbudget_transaction_builder; + use actualbudget_models::{ + ActualbudgetDate, ActualbudgetTransaction, ActualbudgetTransactionBuilder, + }; + use schist_models::{BucketTransaction, DateUtc}; + + use crate::transform_state::transform_id; + + use super::transform_bucket_transactions_from_category_transfers; + + #[test] + fn when_one_category_transfer_then_transforms() { + let actualbudget_transactions = [ + make_fake_parent_transaction_builder("0") + .date(ActualbudgetDate::from_ymd(2026, 01, 16)) + .build() + .unwrap(), + make_fake_child_withdrawal("1", "0"), + make_fake_child_deposit("2", "0"), + ]; + + let result = + transform_bucket_transactions_from_category_transfers(&actualbudget_transactions); + + assert!(result.is_ok()); + let bucket_transactions = result.unwrap(); + assert_eq!(2, bucket_transactions.len()); + + let bucket_withdrawal = bucket_transactions.iter().find(|bt| bt.amount < 0).unwrap(); + assert_eq!(-100, bucket_withdrawal.amount); + assert_eq!("I was a parent transaction", bucket_withdrawal.description); + assert_eq!(transform_id("0"), bucket_withdrawal.bucket_id); + assert_eq!( + DateUtc::from_ymd(2026, 1, 16).unwrap(), + bucket_withdrawal.date + ); + + let bucket_deposit = bucket_transactions.iter().find(|bt| bt.amount > 0).unwrap(); + assert_eq!(100, bucket_deposit.amount); + assert_eq!("I was a parent transaction", bucket_deposit.description); + assert_eq!(transform_id("1"), bucket_deposit.bucket_id); + assert_eq!(DateUtc::from_ymd(2026, 1, 16).unwrap(), bucket_deposit.date); + } + + #[test] + fn when_many_transactions_then_transforms() { + let actualbudget_transactions = [ + make_fake_parent_transaction_builder("0") + .date(ActualbudgetDate::from_ymd(2026, 1, 16)) + .build() + .unwrap(), + make_fake_child_withdrawal_builder("1", 100, "0") + .category_id(Some(String::from("0"))) + .build() + .unwrap(), + make_fake_child_deposit_builder("2", 100, "0") + .category_id(Some(String::from("1"))) + .build() + .unwrap(), + make_fake_parent_transaction_builder("3") + .date(ActualbudgetDate::from_ymd(2026, 1, 21)) + .build() + .unwrap(), + make_fake_child_withdrawal_builder("4", 200, "3") + .category_id(Some(String::from("1"))) + .build() + .unwrap(), + make_fake_child_deposit_builder("5", 200, "3") + .category_id(Some(String::from("0"))) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("6") + .amount(300) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("7") + .amount(-400) + .build() + .unwrap(), + ]; + + let result = + transform_bucket_transactions_from_category_transfers(&actualbudget_transactions); + + assert!(result.is_ok()); + let bucket_transactions = result.unwrap(); + assert_eq!(4, bucket_transactions.len()); + + let bucket_withdrawal_0: Vec<&BucketTransaction> = bucket_transactions + .iter() + .filter(|ct| ct.amount == -100) + .collect(); + assert_eq!(1, bucket_withdrawal_0.len()); + let bucket_withdrawal_0 = bucket_withdrawal_0.get(0).unwrap(); + assert_eq!(transform_id("0"), bucket_withdrawal_0.bucket_id); + assert_eq!( + DateUtc::from_ymd(2026, 1, 16).unwrap(), + bucket_withdrawal_0.date + ); + + let bucket_deposit_0: Vec<&BucketTransaction> = bucket_transactions + .iter() + .filter(|ct| ct.amount == 100) + .collect(); + assert_eq!(1, bucket_deposit_0.len()); + let bucket_deposit_0 = bucket_deposit_0.get(0).unwrap(); + assert_eq!(transform_id("1"), bucket_deposit_0.bucket_id); + assert_eq!( + DateUtc::from_ymd(2026, 1, 16).unwrap(), + bucket_deposit_0.date + ); + + let bucket_withdrawal_1: Vec<&BucketTransaction> = bucket_transactions + .iter() + .filter(|ct| ct.amount == -200) + .collect(); + assert_eq!(1, bucket_withdrawal_1.len()); + let bucket_withdrawal_1 = bucket_withdrawal_1.get(0).unwrap(); + assert_eq!(transform_id("1"), bucket_withdrawal_1.bucket_id); + assert_eq!( + DateUtc::from_ymd(2026, 1, 21).unwrap(), + bucket_withdrawal_1.date + ); + + let bucket_deposit_1: Vec<&BucketTransaction> = bucket_transactions + .iter() + .filter(|ct| ct.amount == 200) + .collect(); + assert_eq!(1, bucket_deposit_1.len()); + let bucket_deposit_1 = bucket_deposit_1.get(0).unwrap(); + assert_eq!(transform_id("0"), bucket_deposit_1.bucket_id); + assert_eq!( + DateUtc::from_ymd(2026, 1, 21).unwrap(), + bucket_deposit_1.date + ); + } + + #[test] + fn when_multiple_child_withdrawals_then_err() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_child_withdrawal_builder("1", 50, "0") + .build() + .unwrap(), + make_fake_child_withdrawal_builder("2", 50, "0") + .build() + .unwrap(), + make_fake_child_deposit_builder("3", 100, "0") + .build() + .unwrap(), + ]; + + let result = + transform_bucket_transactions_from_category_transfers(&actualbudget_transactions); + + assert!(result.is_err()); + } + + #[test] + fn when_multiple_child_deposits_then_err() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_child_withdrawal_builder("1", 100, "0") + .build() + .unwrap(), + make_fake_child_deposit_builder("2", 50, "0") + .build() + .unwrap(), + make_fake_child_deposit_builder("3", 50, "0") + .build() + .unwrap(), + ]; + + let result = + transform_bucket_transactions_from_category_transfers(&actualbudget_transactions); + + assert!(result.is_err()); + } + + #[test] + fn when_no_child_withdrawals_then_return_empty() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_child_deposit("1", "0"), + ]; + + let result = + transform_bucket_transactions_from_category_transfers(&actualbudget_transactions); + + assert!(result.is_ok()); + assert_eq!(0, result.unwrap().len()); + } + + #[test] + fn when_no_child_deposits_then_return_empty() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_child_withdrawal("1", "0"), + ]; + + let result = + transform_bucket_transactions_from_category_transfers(&actualbudget_transactions); + + assert!(result.is_ok()); + assert_eq!(0, result.unwrap().len()); + } + + #[test] + fn when_child_amounts_dont_add_up_to_zero_then_return_empty() { + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_child_withdrawal_builder("1", 50, "0") + .build() + .unwrap(), + make_fake_child_deposit_builder("2", 100, "0") + .build() + .unwrap(), + ]; + + let result = + transform_bucket_transactions_from_category_transfers(&actualbudget_transactions); + + assert!(result.is_ok()); + assert_eq!(0, result.unwrap().len()); + } + + #[test] + fn when_child_category_transfer_has_no_category_then_err() { + let actualbudget_transactions_0 = [ + make_fake_parent_transaction("0"), + make_fake_child_withdrawal_builder("1", 100, "0") + .category_id(None) + .build() + .unwrap(), + make_fake_child_deposit_builder("2", 100, "0") + .build() + .unwrap(), + ]; + let actualbudget_transactions_1 = [ + make_fake_parent_transaction("0"), + make_fake_child_withdrawal_builder("1", 100, "0") + .build() + .unwrap(), + make_fake_child_deposit_builder("2", 100, "0") + .category_id(None) + .build() + .unwrap(), + ]; + + let result_0 = + transform_bucket_transactions_from_category_transfers(&actualbudget_transactions_0); + let result_1 = + transform_bucket_transactions_from_category_transfers(&actualbudget_transactions_1); + + assert!(result_0.is_err()); + assert!(result_1.is_err()); + } + + fn make_fake_parent_transaction(id: &str) -> ActualbudgetTransaction { + make_fake_parent_transaction_builder(id).build().unwrap() + } + + fn make_fake_parent_transaction_builder(id: &str) -> ActualbudgetTransactionBuilder { + let mut builder = make_fake_actualbudget_transaction_builder(id); + builder + .account_id(String::from("0")) + .notes(Some(String::from("I was a parent transaction"))) + .is_parent(true); + builder + } + + fn make_fake_child_withdrawal(id: &str, parent_id: &str) -> ActualbudgetTransaction { + make_fake_child_withdrawal_builder(id, 100, parent_id) + .build() + .unwrap() + } + + fn make_fake_child_withdrawal_builder( + id: &str, + amount: i32, + parent_id: &str, + ) -> ActualbudgetTransactionBuilder { + let mut builder = make_fake_actualbudget_transaction_builder(id); + builder + .account_id(String::from("0")) + .category_id(Some(String::from("0"))) + .amount(-amount) + .is_child(true) + .notes(Some(String::from("I was a child withdrawal"))) + .parent_id(Some(String::from(parent_id))); + builder + } + + fn make_fake_child_deposit(id: &str, parent_id: &str) -> ActualbudgetTransaction { + make_fake_child_deposit_builder(id, 100, parent_id) + .build() + .unwrap() + } + + fn make_fake_child_deposit_builder( + id: &str, + amount: i32, + parent_id: &str, + ) -> ActualbudgetTransactionBuilder { + let mut builder = make_fake_actualbudget_transaction_builder(id); + builder + .account_id(String::from("0")) + .category_id(Some(String::from("1"))) + .amount(amount) + .is_child(true) + .notes(Some(String::from("I was a child deposit"))) + .parent_id(Some(String::from(parent_id))); + builder + } +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs index 835ceff..144913a 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs @@ -1,12 +1,16 @@ use anyhow::{Context, Result}; -use schist_models::{Account, Bucket, BucketTransfer, Pipe, Transaction}; +use schist_models::{Account, Bucket, BucketTransaction, Pipe, Transaction}; -use crate::{actualbudget_state::ActualbudgetState, schist_state::SchistState}; +use crate::{ + actualbudget_state::ActualbudgetState, schist_state::SchistState, + transform_state::transform_bucket_transactions_from_budgets_not_carried_over, +}; use super::{ - transform_account_transfers, transform_accounts, transform_categories_to_buckets, - transform_date, transform_transactions, transform_transactions_to_bucket_transfers, - transform_zero_budgets_to_drips::transform_budget_drips, transform_zero_budgets_to_pipes, + transform_account_transfers, transform_accounts, + transform_bucket_transactions_from_category_transfers, transform_categories_to_buckets, + transform_date, transform_transactions, transform_zero_budgets_to_drips, + transform_zero_budgets_to_pipes, }; pub fn transform_state(actualbudget_state: ActualbudgetState) -> Result<SchistState> { @@ -27,7 +31,8 @@ fn transform_state_without_context(actualbudget_state: ActualbudgetState) -> Res &actualbudget_state.actualbudget_transactions, &first_activity_date, ); - let budget_drips = transform_budget_drips(&actualbudget_state.actualbudget_zero_budgets)?; + let budget_drips = + transform_zero_budgets_to_drips(&actualbudget_state.actualbudget_zero_budgets)?; let buckets: Vec<Bucket> = transform_categories_to_buckets( &actualbudget_state.actualbudget_categories, &actualbudget_state.actualbudget_category_groups, @@ -36,8 +41,16 @@ fn transform_state_without_context(actualbudget_state: ActualbudgetState) -> Res ); let pipes: Vec<Pipe> = transform_zero_budgets_to_pipes(&actualbudget_state.actualbudget_zero_budgets)?; - let bucket_transfers: Vec<BucketTransfer> = - transform_transactions_to_bucket_transfers(&remaining_transactions)?; + let bucket_transactions: Vec<BucketTransaction> = vec![ + transform_bucket_transactions_from_category_transfers(&remaining_transactions)?, + transform_bucket_transactions_from_budgets_not_carried_over( + &actualbudget_state.actualbudget_zero_budgets, + &actualbudget_state.actualbudget_transactions, + )?, + ] + .into_iter() + .flatten() + .collect(); Ok(SchistState { first_transaction_date: first_activity_date, @@ -47,6 +60,6 @@ fn transform_state_without_context(actualbudget_state: ActualbudgetState) -> Res budget_drips, pipes: pipes, transactions, - bucket_transfers, + bucket_transactions, }) } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs index 11765be..dce07af 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs @@ -2,7 +2,7 @@ use actualbudget_models::{ActualbudgetPayee, ActualbudgetTransaction}; use anyhow::{Context, Result}; use schist_models::Transaction; -use super::{is_not_bucket_transfer, transform_date, transform_id}; +use super::{is_not_category_transfer, transform_date, transform_id}; pub fn transform_transactions( actualbudget_transactions: &[ActualbudgetTransaction], @@ -21,7 +21,7 @@ fn transform_transactions_without_context( let actualbudget_transactions = actualbudget_transactions .iter() .filter(|t| !t.is_starting_balance()) - .filter(|t| is_not_bucket_transfer(t, actualbudget_transactions)); + .filter(|t| is_not_category_transfer(t, actualbudget_transactions)); let parent_actualbudget_transactions: Vec<&ActualbudgetTransaction> = actualbudget_transactions .clone() .filter(|t| t.is_parent) diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions_to_bucket_transfers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions_to_bucket_transfers.rs deleted file mode 100644 index 51545c3..0000000 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_transactions_to_bucket_transfers.rs +++ /dev/null @@ -1,383 +0,0 @@ -use actualbudget_models::ActualbudgetTransaction; -use anyhow::{bail, Context, Result}; -use itertools::Itertools; -use schist_models::BucketTransfer; - -use super::transform_id; - -pub fn transform_transactions_to_bucket_transfers( - actualbudget_transactions: &[ActualbudgetTransaction], -) -> Result<Vec<BucketTransfer>> { - let children: Vec<&ActualbudgetTransaction> = actualbudget_transactions - .into_iter() - .filter(|&t| t.is_child) - .collect(); - let parent_children_pairs: Vec<(&ActualbudgetTransaction, Vec<&ActualbudgetTransaction>)> = - actualbudget_transactions - .iter() - .filter(|t| t.is_parent) - .map(|parent| { - ( - parent, - children - .clone() - .into_iter() - .filter(|&c| c.parent_id.clone().is_some_and(|id| id == parent.id)) - .collect::<Vec<&ActualbudgetTransaction>>(), - ) - }) - .map(|(parent, children)| { - let children_total_amount: i32 = children.iter().map(|child| child.amount).sum(); - if parent.amount != children_total_amount { - bail!("parent transaction {} has amount: {}, but its {} children add up to a total amount of: {}", - parent.id, - parent.amount, - children.len(), - children_total_amount); - } - Ok((parent, children)) - }) - .filter(|result| - match result { - Ok((parent, _children)) => parent.amount == 0, - Err(_) => true, - }) - .try_collect()?; - - Ok(parent_children_pairs - .iter() - .map(|(parent, children)| transform_category_transfer(parent, children)) - .filter_ok(Option::is_some) - .map_ok(Option::unwrap) - .collect::<Result<Vec<BucketTransfer>>>() - .with_context(|| "failed to transform category transfers")?) -} - -fn transform_category_transfer( - actualbudget_parent_category_transfer: &ActualbudgetTransaction, - actualbudget_child_category_transfers: &[&ActualbudgetTransaction], -) -> Result<Option<BucketTransfer>> { - transform_category_transfer_without_context( - actualbudget_parent_category_transfer, - actualbudget_child_category_transfers, - ) - .with_context(|| "failed to transform category transfer") -} - -fn transform_category_transfer_without_context( - actualbudget_parent_category_transfer: &ActualbudgetTransaction, - actualbudget_child_category_transfers: &[&ActualbudgetTransaction], -) -> Result<Option<BucketTransfer>> { - let from_transfers: Vec<&ActualbudgetTransaction> = actualbudget_child_category_transfers - .into_iter() - .cloned() - .filter(|&t| t.amount < 0) - .collect(); - if from_transfers.len() != 1 { - bail!( - "actualbudget parent category transfer {} had {} from child transfers", - actualbudget_parent_category_transfer.id, - from_transfers.len(), - ); - } - let from_transfer = from_transfers.get(0).unwrap(); - - if from_transfer.category_id.is_none() { - bail!( - "actualbudget child from transfer {} had no category ID", - from_transfer.id - ); - } - let from_category_id: i32 = transform_id(&from_transfer.category_id.clone().unwrap()); - - let to_transfers: Vec<&ActualbudgetTransaction> = actualbudget_child_category_transfers - .iter() - .filter(|&t| t.amount > 0) - .cloned() - .collect(); - if to_transfers.len() != 1 { - bail!( - "actualbudget parent category transfer {} had {} to child transfers", - actualbudget_parent_category_transfer.id, - to_transfers.len(), - ); - } - let to_transfer = to_transfers.get(0).unwrap(); - - if to_transfer.category_id.is_none() { - bail!( - "actualbudget child to transfer {} had no category ID", - to_transfer.id - ); - } - let to_category_id: i32 = transform_id(&to_transfer.category_id.clone().unwrap()); - - if from_transfer.amount != -to_transfer.amount { - bail!("actualbudget category transfer amounts did not sum to zero"); - } - - Ok(Some(BucketTransfer { - id: transform_id(&actualbudget_parent_category_transfer.id), - description: actualbudget_parent_category_transfer - .notes - .clone() - .unwrap_or_else(String::new), - amount: to_transfer.amount.try_into().unwrap(), - from_bucket_id: from_category_id, - to_bucket_id: to_category_id, - })) -} - -#[cfg(test)] -mod test { - use actualbudget_fakes::actualbudget_transaction::make_fake_actualbudget_transaction_builder; - use actualbudget_models::{ActualbudgetTransaction, ActualbudgetTransactionBuilder}; - use schist_models::BucketTransfer; - - use crate::transform_state::transform_id::transform_id; - - use super::transform_transactions_to_bucket_transfers; - - #[test] - fn when_one_category_transfer_then_transforms() { - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction("1", "0"), - make_fake_to_child_transaction("2", "0"), - ]; - - let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); - - assert!(result.is_ok()); - let bucket_transfers = result.unwrap(); - assert_eq!(1, bucket_transfers.len()); - let bucket_transfer = bucket_transfers.get(0).unwrap(); - assert_eq!(100, bucket_transfer.amount); - assert_eq!("I was a parent transaction", bucket_transfer.description); - assert_eq!(transform_id("0"), bucket_transfer.from_bucket_id); - assert_eq!(transform_id("1"), bucket_transfer.to_bucket_id); - } - - #[test] - fn when_many_transactions_then_transforms() { - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 100, "0") - .category_id(Some(String::from("0"))) - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 100, "0") - .category_id(Some(String::from("1"))) - .build() - .unwrap(), - make_fake_parent_transaction("3"), - make_fake_from_child_transaction_builder("4", 200, "3") - .category_id(Some(String::from("1"))) - .build() - .unwrap(), - make_fake_to_child_transaction_builder("5", 200, "3") - .category_id(Some(String::from("0"))) - .build() - .unwrap(), - make_fake_actualbudget_transaction_builder("6") - .amount(300) - .build() - .unwrap(), - make_fake_actualbudget_transaction_builder("7") - .amount(-400) - .build() - .unwrap(), - ]; - - let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); - - assert!(result.is_ok()); - let bucket_transfers = result.unwrap(); - assert_eq!(2, bucket_transfers.len()); - - let bucket_transfer_0: Vec<&BucketTransfer> = bucket_transfers - .iter() - .filter(|ct| ct.amount == 100) - .collect(); - assert_eq!(1, bucket_transfer_0.len()); - let bucket_transfer_0 = bucket_transfer_0.get(0).unwrap(); - assert_eq!(transform_id("0"), bucket_transfer_0.from_bucket_id); - assert_eq!(transform_id("1"), bucket_transfer_0.to_bucket_id); - - let bucket_transfer_1: Vec<&BucketTransfer> = bucket_transfers - .iter() - .filter(|ct| ct.amount == 200) - .collect(); - assert_eq!(1, bucket_transfer_1.len()); - let bucket_transfer_1 = bucket_transfer_1.get(0).unwrap(); - assert_eq!(transform_id("1"), bucket_transfer_1.from_bucket_id); - assert_eq!(transform_id("0"), bucket_transfer_1.to_bucket_id); - } - - #[test] - fn when_multiple_child_from_transactions_then_err() { - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 50, "0") - .build() - .unwrap(), - make_fake_from_child_transaction_builder("2", 50, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("3", 100, "0") - .build() - .unwrap(), - ]; - - let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); - - assert!(result.is_err()); - } - - #[test] - fn when_multiple_child_to_transactions_then_err() { - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 100, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 50, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("3", 50, "0") - .build() - .unwrap(), - ]; - - let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); - - assert!(result.is_err()); - } - - #[test] - fn when_no_child_from_transactions_then_err() { - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_to_child_transaction("1", "0"), - ]; - - let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); - - assert!(result.is_err()); - } - - #[test] - fn when_no_child_to_transactions_then_err() { - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction("1", "0"), - ]; - - let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); - - assert!(result.is_err()); - } - - #[test] - fn when_child_amounts_dont_add_up_to_zero_then_err() { - let actualbudget_transactions = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 50, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 100, "0") - .build() - .unwrap(), - ]; - - let result = transform_transactions_to_bucket_transfers(&actualbudget_transactions); - - assert!(result.is_err()); - } - - #[test] - fn when_child_category_transfer_has_no_category_then_err() { - let actualbudget_transactions_0 = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 100, "0") - .category_id(None) - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 100, "0") - .build() - .unwrap(), - ]; - let actualbudget_transactions_1 = [ - make_fake_parent_transaction("0"), - make_fake_from_child_transaction_builder("1", 100, "0") - .build() - .unwrap(), - make_fake_to_child_transaction_builder("2", 100, "0") - .category_id(None) - .build() - .unwrap(), - ]; - - let result_0 = transform_transactions_to_bucket_transfers(&actualbudget_transactions_0); - let result_1 = transform_transactions_to_bucket_transfers(&actualbudget_transactions_1); - - assert!(result_0.is_err()); - assert!(result_1.is_err()); - } - - fn make_fake_parent_transaction(id: &str) -> ActualbudgetTransaction { - make_fake_parent_transaction_builder(id).build().unwrap() - } - - fn make_fake_parent_transaction_builder(id: &str) -> ActualbudgetTransactionBuilder { - let mut builder = make_fake_actualbudget_transaction_builder(id); - builder - .account_id(String::from("0")) - .notes(Some(String::from("I was a parent transaction"))) - .is_parent(true); - builder - } - - fn make_fake_from_child_transaction(id: &str, parent_id: &str) -> ActualbudgetTransaction { - make_fake_from_child_transaction_builder(id, 100, parent_id) - .build() - .unwrap() - } - fn make_fake_from_child_transaction_builder( - id: &str, - amount: i32, - parent_id: &str, - ) -> ActualbudgetTransactionBuilder { - let mut builder = make_fake_actualbudget_transaction_builder(id); - builder - .account_id(String::from("0")) - .category_id(Some(String::from("0"))) - .amount(-amount) - .is_child(true) - .notes(Some(String::from("I was a from child transaction"))) - .parent_id(Some(String::from(parent_id))); - builder - } - - fn make_fake_to_child_transaction(id: &str, parent_id: &str) -> ActualbudgetTransaction { - make_fake_to_child_transaction_builder(id, 100, parent_id) - .build() - .unwrap() - } - - fn make_fake_to_child_transaction_builder( - id: &str, - amount: i32, - parent_id: &str, - ) -> ActualbudgetTransactionBuilder { - let mut builder = make_fake_actualbudget_transaction_builder(id); - builder - .account_id(String::from("0")) - .category_id(Some(String::from("1"))) - .amount(amount) - .is_child(true) - .notes(Some(String::from("I was a to child transaction"))) - .parent_id(Some(String::from(parent_id))); - builder - } -} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_drips.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_drips.rs index bfaef31..b5e6734 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_drips.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_zero_budgets_to_drips.rs @@ -8,7 +8,7 @@ use schist_traits::Dateable; use super::{transform_date::transform_date, transform_id::transform_id}; -pub fn transform_budget_drips( +pub fn transform_zero_budgets_to_drips( actualbudget_zero_budgets: &[ActualbudgetZeroBudget], ) -> Result<Vec<Drip>> { let non_zero_actualbudget_zero_budgets: Vec<ActualbudgetZeroBudget> = actualbudget_zero_budgets @@ -133,7 +133,7 @@ mod test { use crate::transform_state::transform_id::transform_id; - use super::transform_budget_drips; + use super::transform_zero_budgets_to_drips; mod map_actualbudget_zero_budget_to_budget_drips { use actualbudget_fakes::actualbudget_zero_budget::make_fake_actualbudget_zero_budget_builder; @@ -195,7 +195,7 @@ mod test { #[test] fn when_no_zero_budgets_then_no_budget_drips() { let actualbudget_zero_budgets = []; - let result = transform_budget_drips(&actualbudget_zero_budgets); + let result = transform_zero_budgets_to_drips(&actualbudget_zero_budgets); assert!(result.is_ok()); assert_eq!(result.unwrap().len(), 0); } @@ -209,7 +209,7 @@ mod test { .build() .unwrap()]; - let result = transform_budget_drips(&actualbudget_zero_budgets); + let result = transform_zero_budgets_to_drips(&actualbudget_zero_budgets); assert!(result.is_ok()); let mut budget_drips = result.unwrap(); @@ -233,7 +233,7 @@ mod test { .build() .unwrap()]; - let result = transform_budget_drips(&actualbudget_zero_budgets); + let result = transform_zero_budgets_to_drips(&actualbudget_zero_budgets); assert!(result.is_ok()); let budget_drips = result.unwrap(); @@ -278,7 +278,7 @@ mod test { .unwrap(), ]; - let result = transform_budget_drips(&actualbudget_zero_budgets); + let result = transform_zero_budgets_to_drips(&actualbudget_zero_budgets); let category_0_id = transform_id("0"); let category_1_id = transform_id("1"); diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_transaction_matchers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_transaction_matchers.rs new file mode 100644 index 0000000..95bb54f --- /dev/null +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_transaction_matchers.rs @@ -0,0 +1,66 @@ +use schist_models::{Bucket, BucketTransaction}; +use schist_traits::Dateable; + +use super::bucket_names::{BUCKET_NAME_GENERAL, BUCKET_NAME_SAVINGS}; + +pub fn get_bucket_transaction_matchers() -> Vec<Box<dyn Fn(&BucketTransaction, &[Bucket]) -> bool>> +{ + vec![ + Box::new(|ct, c| matches_withdrawal_120_savings_23_oct_2024(ct, c)), + Box::new(|ct, c| matches_deposit_120_general_23_oct_2024(ct, c)), + ] +} + +fn matches_withdrawal_120_savings_23_oct_2024( + bucket_transactions: &BucketTransaction, + buckets: &[Bucket], +) -> bool { + let savings_bucket = buckets + .iter() + .find(|c| c.name == BUCKET_NAME_SAVINGS) + .expect("failed to find savings bucket"); + + match bucket_transactions { + BucketTransaction { + id: _id, + description, + amount: -120_00, + date, + bucket_id, + } => { + *bucket_id == savings_bucket.id + && description.as_str() == "" + && date.year() == 2024 + && date.month() == 10 + && date.day() == 23 + } + _ => false, + } +} + +fn matches_deposit_120_general_23_oct_2024( + bucket_transaction: &BucketTransaction, + buckets: &[Bucket], +) -> bool { + let general_bucket = buckets + .iter() + .find(|c| c.name == BUCKET_NAME_GENERAL) + .expect("failed to find general bucket"); + + match bucket_transaction { + BucketTransaction { + id: _id, + description, + amount: 120_00, + date, + bucket_id, + } => { + *bucket_id == general_bucket.id + && description.as_str() == "" + && date.year() == 2024 + && date.month() == 10 + && date.day() == 23 + } + _ => false, + } +} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_transfer_matchers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_transfer_matchers.rs deleted file mode 100644 index c31b9bc..0000000 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/bucket_transfer_matchers.rs +++ /dev/null @@ -1,39 +0,0 @@ -use schist_models::{Bucket, BucketTransfer}; - -use super::bucket_names::{BUCKET_NAME_GENERAL, BUCKET_NAME_SAVINGS}; - -pub fn get_bucket_transfer_matchers() -> Vec<Box<dyn Fn(&BucketTransfer, &[Bucket]) -> bool>> { - vec![Box::new(|ct, c| { - matches_120_savings_to_general_23_oct_2024(ct, c) - })] -} - -fn matches_120_savings_to_general_23_oct_2024( - bucket_transfer: &BucketTransfer, - buckets: &[Bucket], -) -> bool { - let savings_bucket = buckets - .iter() - .find(|c| c.name == BUCKET_NAME_SAVINGS) - .expect("failed to find savings bucket"); - - let general_bucket = buckets - .iter() - .find(|c| c.name == BUCKET_NAME_GENERAL) - .expect("failed to find general bucket"); - - match bucket_transfer { - BucketTransfer { - id: _id, - description, - amount: 120_00, - from_bucket_id, - to_bucket_id, - } => { - *from_bucket_id == savings_bucket.id - && *to_bucket_id == general_bucket.id - && description.as_str() == "" - } - _ => false, - } -} diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/drip_matchers.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/drip_matchers.rs index 629a04b..ccb1715 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/drip_matchers.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/drip_matchers.rs @@ -33,7 +33,7 @@ pub fn assert_drip_matches(drip: &Drip, buckets: &[Bucket]) { .as_str(), ); - let expected_quantity = match (bucket_name.as_str(), drip.date.month(), drip.date.day()) { + let expected_amount = match (bucket_name.as_str(), drip.date.month(), drip.date.day()) { (BUCKET_NAME_FOOD, 8 | 10, 1) => 6_50, (BUCKET_NAME_FOOD, 8 | 10, _) => 6_45, (BUCKET_NAME_FOOD, 9 | 11, 1) => 6_86, @@ -70,8 +70,8 @@ pub fn assert_drip_matches(drip: &Drip, buckets: &[Bucket]) { }; assert_eq!( - expected_quantity, drip.amount, - "drip {:?} had unexpected quantity", + expected_amount, drip.amount, + "drip {:?} had unexpected amount", drip, ); } diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/mod.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/mod.rs index 82d7870..1dd718c 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/mod.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/common/mod.rs @@ -3,7 +3,7 @@ mod account_names; mod account_transfer_matchers; mod bucket_matchers; mod bucket_names; -mod bucket_transfer_matchers; +mod bucket_transaction_matchers; mod db_url; mod drip_matchers; mod pipe_matchers; @@ -15,7 +15,7 @@ pub use account_names::ACCOUNT_NAMES_DICT; pub use account_transfer_matchers::get_account_transfer_matchers; pub use bucket_matchers::get_bucket_matchers; pub use bucket_names::BUCKET_NAMES_DICT; -pub use bucket_transfer_matchers::get_bucket_transfer_matchers; +pub use bucket_transaction_matchers::get_bucket_transaction_matchers; pub use db_url::TEST_IN_DB_URL; pub use db_url::TEST_OUT_DB_URL; pub use drip_matchers::assert_drip_matches; diff --git a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/run.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/run.rs index 04c21ca..1232016 100644 --- a/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/run.rs +++ b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/tests/run.rs @@ -3,15 +3,15 @@ pub mod common; use actualbudget_to_schist_transformer::run::run; use common::{ assert_drip_matches, get_account_matchers, get_account_transfer_matchers, get_bucket_matchers, - get_bucket_transfer_matchers, get_num_expected_drips, get_transaction_matchers, TestContext, + get_bucket_transaction_matchers, get_num_expected_drips, get_transaction_matchers, TestContext, TEST_IN_DB_URL, TEST_OUT_DB_URL, }; use diesel::{Connection, SqliteConnection}; use schist_queries::{ account_transfers::get_all_account_transfers, accounts::get_all_accounts, - bucket_transfers::get_all_bucket_transfers, buckets::get_all_buckets, drips::get_all_drips, - pipes::get_all_pipes, transactions::get_all_transactions, + bucket_transactions::get_all_bucket_transactions, buckets::get_all_buckets, + drips::get_all_drips, pipes::get_all_pipes, transactions::get_all_transactions, }; use crate::common::get_pipe_matchers; @@ -25,7 +25,7 @@ fn given_test_database_when_run_then_exports_expected_schist_state() { let account_transfers = get_all_account_transfers(connection).unwrap(); let drips = get_all_drips(connection).unwrap(); let buckets = get_all_buckets(connection).unwrap(); - let bucket_transfers = get_all_bucket_transfers(connection).unwrap(); + let bucket_transactions = get_all_bucket_transactions(connection).unwrap(); let pipes = get_all_pipes(connection).unwrap(); let transactions = get_all_transactions(connection).unwrap(); @@ -66,15 +66,15 @@ fn given_test_database_when_run_then_exports_expected_schist_state() { ); } - let bucket_transfer_matchers = get_bucket_transfer_matchers(); - assert_eq!(bucket_transfer_matchers.len(), bucket_transfers.len()); - for bucket_transfer in bucket_transfers { + let bucket_transaction_matchers = get_bucket_transaction_matchers(); + assert_eq!(bucket_transaction_matchers.len(), bucket_transactions.len()); + for bucket_transaction in bucket_transactions { assert!( - bucket_transfer_matchers + bucket_transaction_matchers .iter() - .any(|m| m(&bucket_transfer, &buckets)), - "no match found for bucket_transfer {:?}", - bucket_transfer, + .any(|m| m(&bucket_transaction, &buckets)), + "no match found for bucket_transaction {:?}", + bucket_transaction, ); } |
