diff options
Diffstat (limited to 'actualbudget_to_schist_transformer/src/transform_state')
12 files changed, 1622 insertions, 0 deletions
diff --git a/actualbudget_to_schist_transformer/src/transform_state/is_account_transfer.rs b/actualbudget_to_schist_transformer/src/transform_state/is_account_transfer.rs new file mode 100644 index 0000000..c9540ed --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/is_account_transfer.rs @@ -0,0 +1,13 @@ +use actualbudget_models::ActualbudgetTransaction; + +pub fn is_account_transfer( + transaction: &ActualbudgetTransaction, +) -> bool { + transaction.transfer_id.is_some() +} + +pub fn is_not_account_transfer( + transaction: &ActualbudgetTransaction, +) -> bool { + transaction.transfer_id.is_none() +} diff --git a/actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs b/actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs new file mode 100644 index 0000000..c82387c --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs @@ -0,0 +1,18 @@ +use actualbudget_models::{ActualbudgetAccount, ActualbudgetTransaction}; + +pub fn is_category_transfer( + transaction: &ActualbudgetTransaction, + actualbudget_category_transfers_account: &Option<ActualbudgetAccount> +) -> bool { + match actualbudget_category_transfers_account { + None => false, + Some(acc) => transaction.account_id.clone() == acc.id.clone(), + } +} + +pub fn is_not_category_transfer( + transaction: &ActualbudgetTransaction, + actualbudget_category_transfers_account: &Option<ActualbudgetAccount> +) -> bool { + !is_category_transfer(transaction, actualbudget_category_transfers_account) +} diff --git a/actualbudget_to_schist_transformer/src/transform_state/mod.rs b/actualbudget_to_schist_transformer/src/transform_state/mod.rs new file mode 100644 index 0000000..8787699 --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/mod.rs @@ -0,0 +1,25 @@ +mod is_account_transfer; +mod is_category_transfer; +mod transform_account_transfers; +mod transform_accounts; +mod transform_budget_drips; +mod transform_categories; +mod transform_category_transfers; +mod transform_date; +mod transform_id; +mod transform_state; +mod transform_transactions; + +use is_account_transfer::is_account_transfer; +use is_account_transfer::is_not_account_transfer; +use is_category_transfer::is_category_transfer; +use is_category_transfer::is_not_category_transfer; +use transform_accounts::transform_accounts; +use transform_account_transfers::transform_account_transfers; +use transform_categories::transform_categories; +use transform_category_transfers::transform_category_transfers; +use transform_date::transform_date; +use transform_id::transform_id; +use transform_transactions::transform_transactions; + +pub use transform_state::transform_state; diff --git a/actualbudget_to_schist_transformer/src/transform_state/transform_account_transfers.rs b/actualbudget_to_schist_transformer/src/transform_state/transform_account_transfers.rs new file mode 100644 index 0000000..a0c426d --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/transform_account_transfers.rs @@ -0,0 +1,306 @@ +use actualbudget_models::ActualbudgetTransaction; +use anyhow::{Context, Result}; +use itertools::Itertools; +use schist_models::account_transfer::AccountTransfer; + +use super::{is_account_transfer, is_not_account_transfer, transform_date::transform_date, transform_id::transform_id}; + +pub fn transform_account_transfers( + actualbudget_transactions: &[ActualbudgetTransaction], +) -> Result<(Vec<ActualbudgetTransaction>, Vec<AccountTransfer>)> { + transform_account_transfers_without_context(actualbudget_transactions) + .with_context(|| "failed to transform account transfers") +} + +pub fn transform_account_transfers_without_context( + actualbudget_transactions: &[ActualbudgetTransaction], +) -> Result<(Vec<ActualbudgetTransaction>, Vec<AccountTransfer>)> { + let mut account_transfer_transaction_pairs: Vec<(ActualbudgetTransaction, ActualbudgetTransaction)> = Vec::new(); + let mut transactions_not_transformed: Vec<ActualbudgetTransaction> = Vec::new(); + let account_transfer_transactions = actualbudget_transactions + .iter() + .cloned() + .filter(is_account_transfer); + + for actualbudget_transaction in actualbudget_transactions { + if is_not_account_transfer(actualbudget_transaction) { + transactions_not_transformed.push(actualbudget_transaction.clone()); + continue; + } + + let is_already_transformed = |t: &ActualbudgetTransaction| + account_transfer_transaction_pairs + .iter().any(|(t1, t2)| *t1 == *t || *t2 == *t); + + if is_already_transformed(actualbudget_transaction) { + continue; + } + + let companion = account_transfer_transactions + .clone() + .filter(|t| !is_already_transformed(t) + && t.date == actualbudget_transaction.date + && t.amount == -actualbudget_transaction.amount + ).last(); + + if companion.is_none() { + transactions_not_transformed.push(actualbudget_transaction.clone()); + continue; + } + let companion = companion.unwrap(); + + let (from, to) = if actualbudget_transaction.amount >= 0 { + (companion.clone(), actualbudget_transaction.clone()) + } else { + (actualbudget_transaction.clone(), companion.clone()) + }; + + account_transfer_transaction_pairs.push((from, to)); + } + + Ok(( + transactions_not_transformed, + account_transfer_transaction_pairs + .iter() + .map(transform_account_transfer) + .try_collect()?, + )) +} + +fn transform_account_transfer( + (actualbudget_transaction_from, actualbudget_transaction_to): + &(ActualbudgetTransaction, ActualbudgetTransaction), +) -> Result<AccountTransfer> { + let context = || format!( + "failed to transform account transfer from transactions ({:?}, {:?})", + actualbudget_transaction_from, + actualbudget_transaction_to, + ); + + Ok(AccountTransfer { + id: transform_id(&actualbudget_transaction_from.id), + date: transform_date(&actualbudget_transaction_from.date) + .with_context(context)?, + description: actualbudget_transaction_from.notes.clone().unwrap_or_else(|| + actualbudget_transaction_to.notes.clone().unwrap_or_else(String::new) + ), + quantity: actualbudget_transaction_to.amount, + from_account_id: transform_id(actualbudget_transaction_from.account_id.as_str()), + to_account_id: transform_id(actualbudget_transaction_to.account_id.as_str()), + }) +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::actualbudget_transaction::make_fake_actualbudget_transaction_builder; + use actualbudget_models::ActualbudgetDate; + use schist_models::date_utc::DateUtc; + + use crate::transform_state::transform_id::transform_id; + + use super::transform_account_transfers; + + #[test] + fn when_one_account_transfer_then_transforms() { + let transactions = [ + make_fake_actualbudget_transaction_builder("0") + .amount(-100) + .account_id(String::from("0")) + .payee(Some(String::from("0"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .notes(Some(String::from("My notes"))) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("1") + .amount(100) + .account_id(String::from("1")) + .payee(Some(String::from("1"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .notes(Some(String::from("My notes"))) + .build() + .unwrap(), + ]; + + let (remaining_transactions, account_transfers) = + transform_account_transfers(&transactions).unwrap(); + + assert_eq!(0, remaining_transactions.len()); + assert_eq!(1, account_transfers.len()); + let account_transfer = account_transfers.get(0).unwrap(); + assert_eq!("2024-08-01".parse::<DateUtc>().unwrap(), account_transfer.date); + assert_eq!("My notes", account_transfer.description); + assert_eq!(100, account_transfer.quantity); + assert_eq!(transform_id("0"), account_transfer.from_account_id); + assert_eq!(transform_id("1"), account_transfer.to_account_id); + } + + #[test] + fn when_one_account_transfer_in_reverse_order_then_transforms() { + let transactions = [ + make_fake_actualbudget_transaction_builder("0") + .amount(100) + .account_id(String::from("1")) + .payee(Some(String::from("1"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .notes(Some(String::from("My notes"))) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("1") + .amount(-100) + .account_id(String::from("0")) + .payee(Some(String::from("0"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .notes(Some(String::from("My notes"))) + .build() + .unwrap(), + ]; + + let (remaining_transactions, account_transfers) = + transform_account_transfers(&transactions).unwrap(); + + assert_eq!(0, remaining_transactions.len()); + assert_eq!(1, account_transfers.len()); + let account_transfer = account_transfers.get(0).unwrap(); + assert_eq!("2024-08-01".parse::<DateUtc>().unwrap(), account_transfer.date); + assert_eq!("My notes", account_transfer.description); + assert_eq!(100, account_transfer.quantity); + assert_eq!(transform_id("0"), account_transfer.from_account_id); + assert_eq!(transform_id("1"), account_transfer.to_account_id); + } + + #[test] + fn when_two_account_transfers_different_amounts_then_transforms() { + let transactions = [ + make_fake_actualbudget_transaction_builder("0") + .amount(-100) + .account_id(String::from("0")) + .payee(Some(String::from("0"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .notes(Some(String::from("My notes 1"))) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("1") + .amount(-200) + .account_id(String::from("0")) + .payee(Some(String::from("0"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .notes(Some(String::from("My notes 2"))) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("2") + .amount(100) + .account_id(String::from("1")) + .payee(Some(String::from("1"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("2") + .amount(200) + .account_id(String::from("1")) + .payee(Some(String::from("1"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .build() + .unwrap(), + ]; + + let (remaining_transactions, account_transfers) = + transform_account_transfers(&transactions).unwrap(); + + assert_eq!(0, remaining_transactions.len()); + assert_eq!(2, account_transfers.len()); + + let account_transfer_0 = account_transfers + .iter() + .filter(|at| at.quantity == 100) + .last() + .unwrap(); + assert_eq!("2024-08-01".parse::<DateUtc>().unwrap(), account_transfer_0.date); + assert_eq!(transform_id("0"), account_transfer_0.from_account_id); + assert_eq!(transform_id("1"), account_transfer_0.to_account_id); + assert_eq!("My notes 1", account_transfer_0.description); + + let account_transfer_1 = account_transfers + .iter() + .filter(|at| at.quantity == 200) + .last() + .unwrap(); + assert_eq!("2024-08-01".parse::<DateUtc>().unwrap(), account_transfer_1.date); + assert_eq!(transform_id("0"), account_transfer_1.from_account_id); + assert_eq!(transform_id("1"), account_transfer_1.to_account_id); + assert_eq!("My notes 2", account_transfer_1.description); + } + + #[test] + fn when_two_account_transfers_different_dates_then_transforms() { + let transactions = [ + make_fake_actualbudget_transaction_builder("0") + .amount(-100) + .account_id(String::from("0")) + .payee(Some(String::from("0"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .notes(Some(String::from("My notes 1"))) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("1") + .amount(-100) + .account_id(String::from("0")) + .payee(Some(String::from("0"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240802)) + .notes(Some(String::from("My notes 2"))) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("2") + .amount(100) + .account_id(String::from("1")) + .payee(Some(String::from("1"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240801)) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("3") + .amount(100) + .account_id(String::from("1")) + .payee(Some(String::from("1"))) + .transfer_id(Some(String::from("0"))) + .date(ActualbudgetDate::from_i32(20240802)) + .build() + .unwrap(), + ]; + + let (remaining_transactions, account_transfers) = + transform_account_transfers(&transactions).unwrap(); + + assert_eq!(0, remaining_transactions.len()); + assert_eq!(2, account_transfers.len()); + + let account_transfer_0 = account_transfers + .iter() + .filter(|at| at.date == "2024-08-01".parse().unwrap()) + .last() + .unwrap(); + assert_eq!(100, account_transfer_0.quantity); + assert_eq!(transform_id("0"), account_transfer_0.from_account_id); + assert_eq!(transform_id("1"), account_transfer_0.to_account_id); + assert_eq!("My notes 1", account_transfer_0.description); + + let account_transfer_1 = account_transfers + .iter() + .filter(|at| at.date == "2024-08-02".parse().unwrap()) + .last() + .unwrap(); + assert_eq!(100, account_transfer_1.quantity); + assert_eq!(transform_id("0"), account_transfer_1.from_account_id); + assert_eq!(transform_id("1"), account_transfer_1.to_account_id); + assert_eq!("My notes 2", account_transfer_1.description); + } +} diff --git a/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs b/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs new file mode 100644 index 0000000..28d8162 --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs @@ -0,0 +1,30 @@ +use actualbudget_models::ActualbudgetAccount; +use schist_models::{account::Account, date_utc::DateUtc}; + +use super::transform_id; + +pub fn transform_accounts( + actualbudget_accounts: &[ActualbudgetAccount], + actualbudget_category_transfers_account: &Option<ActualbudgetAccount>, + first_activity_date: &DateUtc, +) -> Vec<Account> { + actualbudget_accounts + .iter() + .filter(|a| actualbudget_category_transfers_account + .clone() + .is_some_and(|cta| cta.id != a.id) + ).map(|a| transform_account(a, first_activity_date)) + .collect() +} + +fn transform_account( + actualbudget_account: &ActualbudgetAccount, + opening_date: &DateUtc, +) -> Account { + Account { + id: transform_id(&actualbudget_account.id.clone()), + name: actualbudget_account.name.clone(), + opening_balance: 0, + opening_date: opening_date.clone(), + } +} diff --git a/actualbudget_to_schist_transformer/src/transform_state/transform_budget_drips.rs b/actualbudget_to_schist_transformer/src/transform_state/transform_budget_drips.rs new file mode 100644 index 0000000..1163e56 --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/transform_budget_drips.rs @@ -0,0 +1,306 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +use actualbudget_models::ActualbudgetZeroBudget; +use anyhow::{Context, Result}; +use itertools::Itertools; +use schist_models::{budget_drip::BudgetDrip, date_utc::DateUtc}; +use schist_traits::dateable::Dateable; + +use super::{transform_date::transform_date, transform_id::transform_id}; + +pub fn transform_budget_drips(actualbudget_zero_budgets: &[ActualbudgetZeroBudget]) -> Result<Vec<BudgetDrip>> { + let non_zero_actualbudget_zero_budgets: Vec<ActualbudgetZeroBudget> = actualbudget_zero_budgets + .iter() + .filter(|zb| zb.amount != 0) + .cloned() + .collect(); + + let budget_drips = group_actualbudget_zero_budgets_by_category_id( + &non_zero_actualbudget_zero_budgets, + ).iter() + .map(sort_actualbudget_zero_budgets_by_month) + .flat_map(|sorted_actualbudget_zero_budgets| sorted_actualbudget_zero_budgets + .iter() + .map(map_actualbudget_zero_budget_to_budget_drips) + .collect::<Result<Vec<Vec<BudgetDrip>>>>() + ).flatten() + .flatten() + .filter(|bd| bd.quantity != 0) + .collect(); + + Ok(budget_drips) +} + +fn map_actualbudget_zero_budget_to_budget_drips( + actualbudget_zero_budget: &ActualbudgetZeroBudget, +) -> Result<Vec<BudgetDrip>> { + let context = || "failed to map actualbudget zero budgets to budget drips"; + + let first_day_of_month: DateUtc = Into::<DateUtc>::into(transform_date(&actualbudget_zero_budget.month).with_context(context)?); + let month = first_day_of_month.month(); + let days_in_month: Vec<DateUtc> = (0..31) + .map(|i| first_day_of_month.add_days(i)) + .take_while(|d| match d { + Err(_) => true, + Ok(d) => d.month() == month, + }) + .filter(Result::is_ok) + .map(Result::unwrap) + .collect(); + + // unwrap() can panic, but days_in_month is guaranteed to have between 0 and + // 31 elements, so we should be grand + let num_days_in_month: i32 = days_in_month.len().try_into().unwrap(); + + let quantity = actualbudget_zero_budget.amount / num_days_in_month; + let remainder_quantity = actualbudget_zero_budget.amount - (quantity * num_days_in_month); + + let budget_drips: Vec<BudgetDrip> = days_in_month.iter().map(|&date| + BudgetDrip { + id: transform_budget_drip_id(&actualbudget_zero_budget, &date), + category_id: transform_id(&actualbudget_zero_budget.category_id), + date, + quantity: if date == first_day_of_month { + quantity + remainder_quantity + } else { + quantity + } + } + ).collect(); + + Ok(budget_drips) +} + +fn sort_actualbudget_zero_budgets_by_month( + actualbudget_zero_budgets_by_category_id: &Vec<ActualbudgetZeroBudget>, +) -> Vec<ActualbudgetZeroBudget> { + let mut actualbudget_zero_budgets = actualbudget_zero_budgets_by_category_id.clone(); + actualbudget_zero_budgets.sort_by_key(|zb| zb.month.clone()); + actualbudget_zero_budgets +} + +fn group_actualbudget_zero_budgets_by_category_id( + actualbudget_zero_budgets: &[ActualbudgetZeroBudget], +) -> Vec<Vec<ActualbudgetZeroBudget>> { + let category_ids = actualbudget_zero_budgets + .iter() + .map(|zb| zb.category_id.clone()) + .unique(); + + category_ids + .map(|category_id| actualbudget_zero_budgets + .iter() + .filter(|zb| zb.category_id == category_id) + .map(ActualbudgetZeroBudget::clone) + .collect() + ).collect() +} + +fn transform_budget_drip_id( + actualbudget_zero_budget: &ActualbudgetZeroBudget, + date: &DateUtc, +) -> i32 { + let mut hasher = DefaultHasher::new(); + let unique_str = actualbudget_zero_budget.id.clone() + date.to_string().as_str(); + unique_str.hash(&mut hasher); + (hasher.finish() % std::convert::TryInto::<u64>::try_into(i32::MAX).unwrap()) + .try_into() + .unwrap() +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::actualbudget_zero_budget::make_fake_actualbudget_zero_budget_builder; + use actualbudget_models::ActualbudgetDate; + use schist_models::budget_drip::BudgetDrip; + use schist_traits::dateable::Dateable; + + use crate::transform_state::transform_id::transform_id; + + use super::transform_budget_drips; + + mod map_actualbudget_zero_budget_to_budget_drips { + use actualbudget_fakes::actualbudget_zero_budget::make_fake_actualbudget_zero_budget_builder; + use actualbudget_models::ActualbudgetDate; + use schist_models::budget_drip::BudgetDrip; + use schist_traits::dateable::Dateable; + + use crate::transform_state::transform_budget_drips::map_actualbudget_zero_budget_to_budget_drips; + + #[test] + fn when_zero_budget_with_integer_budget_per_day_then_evenly_divided_budget_drips() { + let actualbudget_zero_budget = make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap(); + + let result = map_actualbudget_zero_budget_to_budget_drips(&actualbudget_zero_budget); + + assert!(result.is_ok()); + let mut budget_drips = result.unwrap(); + assert_eq!(31, budget_drips.len()); + assert!(budget_drips.iter().all(|bd| bd.quantity == 100)); + budget_drips.sort_by_key(|bd| bd.date); + for i in 1..31 { + assert_eq!(Ok(i), budget_drips.get(i - 1).unwrap().date.day().try_into()); + } + } + + #[test] + fn when_zero_budget_with_non_integer_budget_per_day_then_remainder_on_one_day() { + let actualbudget_zero_budget = make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100 + 30) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap(); + + let result = map_actualbudget_zero_budget_to_budget_drips(&actualbudget_zero_budget); + + assert!(result.is_ok()); + let budget_drips = result.unwrap(); + assert_eq!(31, budget_drips.len()); + + let budget_drips_quantity_not_floored: Vec<&BudgetDrip> = budget_drips + .iter() + .filter(|bd| bd.quantity != 100) + .collect(); + assert_eq!(1, budget_drips_quantity_not_floored.len()); + + let budget_drips_floored: Vec<&BudgetDrip> = budget_drips + .iter() + .filter(|bd| bd.quantity == 100) + .collect(); + assert_eq!(30, budget_drips_floored.len()); + } + } + + #[test] + fn when_no_zero_budgets_then_no_budget_drips() { + let actualbudget_zero_budgets = []; + let result = transform_budget_drips(&actualbudget_zero_budgets); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 0); + } + + #[test] + fn when_one_zero_budget_with_integer_budget_per_day_then_evenly_divided_budget_drips() { + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap() + ]; + + let result = transform_budget_drips(&actualbudget_zero_budgets); + + assert!(result.is_ok()); + let mut budget_drips = result.unwrap(); + assert_eq!(31, budget_drips.len()); + assert!(budget_drips.iter().all(|bd| bd.quantity == 100)); + budget_drips.sort_by_key(|bd| bd.date); + for i in 1..31 { + assert_eq!(Ok(i), budget_drips.get(i - 1).unwrap().date.day().try_into()); + } + } + + #[test] + fn when_one_zero_budgets_with_non_integer_budget_per_day_then_remainder_on_one_day() { + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100 + 30) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap() + ]; + + let result = transform_budget_drips(&actualbudget_zero_budgets); + + assert!(result.is_ok()); + let budget_drips = result.unwrap(); + assert_eq!(31, budget_drips.len()); + + let budget_drips_quantity_not_floored: Vec<&BudgetDrip> = budget_drips + .iter() + .filter(|bd| bd.quantity != 100) + .collect(); + assert_eq!(1, budget_drips_quantity_not_floored.len()); + + let budget_drips_floored: Vec<&BudgetDrip> = budget_drips + .iter() + .filter(|bd| bd.quantity == 100) + .collect(); + assert_eq!(30, budget_drips_floored.len()); + } + + #[test] + fn when_multiple_zero_budgets_with_integer_budget_per_day_across_multiple_categories_then_evenly_divided_budget_drips() { + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("0") + .amount(31 * 100) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("1") + .amount(31 * 200) + .category_id(String::from("1")) + .month(ActualbudgetDate::from_i32(202401)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("2") + .amount(29 * 110) + .category_id(String::from("0")) + .month(ActualbudgetDate::from_i32(202402)) + .build() + .unwrap(), + make_fake_actualbudget_zero_budget_builder("3") + .amount(29 * 210) + .category_id(String::from("1")) + .month(ActualbudgetDate::from_i32(202402)) + .build() + .unwrap(), + ]; + + let result = transform_budget_drips(&actualbudget_zero_budgets); + let category_0_id = transform_id("0"); + let category_1_id = transform_id("1"); + + assert!(result.is_ok()); + let budget_drips = result.unwrap(); + assert_eq!(31 + 31 + 29 + 29, budget_drips.len()); + + let budget_drips_jan_category_0: Vec<&BudgetDrip> = budget_drips + .iter() + .filter(|bd| bd.category_id == category_0_id && bd.date.month() == 1) + .collect(); + assert_eq!(31, budget_drips_jan_category_0.len()); + assert!(budget_drips_jan_category_0.iter().all(|bd| bd.quantity == 100)); + + let budget_drips_jan_category_1: Vec<&BudgetDrip> = budget_drips + .iter() + .filter(|bd| bd.category_id == category_1_id && bd.date.month() == 1) + .collect(); + assert_eq!(31, budget_drips_jan_category_1.len()); + assert!(budget_drips_jan_category_1.iter().all(|bd| bd.quantity == 200)); + + let budget_drips_feb_category_0: Vec<&BudgetDrip> = budget_drips + .iter() + .filter(|bd| bd.category_id == category_0_id && bd.date.month() == 2) + .collect(); + assert_eq!(29, budget_drips_feb_category_0.len()); + assert!(budget_drips_feb_category_0.iter().all(|bd| bd.quantity == 110)); + + let budget_drips_feb_category_1: Vec<&BudgetDrip> = budget_drips + .iter() + .filter(|bd| bd.category_id == category_1_id && bd.date.month() == 2) + .collect(); + assert_eq!(29, budget_drips_feb_category_1.len()); + assert!(budget_drips_feb_category_1.iter().all(|bd| bd.quantity == 210)); + } +} diff --git a/actualbudget_to_schist_transformer/src/transform_state/transform_categories.rs b/actualbudget_to_schist_transformer/src/transform_state/transform_categories.rs new file mode 100644 index 0000000..17ce194 --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/transform_categories.rs @@ -0,0 +1,204 @@ +use actualbudget_models::{ActualbudgetCategory, ActualbudgetZeroBudget}; +use itertools::Itertools; +use schist_models::{budget_period_unit::BudgetPeriodUnit, category::Category, date_utc::DateUtc}; + +use super::transform_id; + +pub fn transform_categories( + actualbudget_categories: &[ActualbudgetCategory], + actualbudget_zero_budgets: &[ActualbudgetZeroBudget], + before_first_activity: &DateUtc, +) -> Vec<Category> { + let last_non_zero_month = actualbudget_zero_budgets + .iter() + .map(|zb| zb.month.clone()) + .unique() + .filter(|month| actualbudget_zero_budgets + .iter() + .any(|zb| zb.month == *month && zb.amount != 0) + ).max(); + + let actualbudget_zero_budgets = if let Some(last_non_zero_month) = last_non_zero_month { + &actualbudget_zero_budgets + .iter() + .filter(|zb| zb.month == last_non_zero_month) + .cloned() + .collect::<Vec<_>>() + } else { + actualbudget_zero_budgets + }; + + actualbudget_categories.iter().map(|actualbudget_category| Category { + id: transform_id(&actualbudget_category.id.clone()), + name: actualbudget_category.name.clone(), + balance: 0, + balance_date: before_first_activity.clone(), + budget_period: 1, + budget_period_unit: BudgetPeriodUnit::Month, + budget_quantity: get_budget_quantity(actualbudget_category, actualbudget_zero_budgets), + }).collect() +} + +fn get_budget_quantity( + actualbudget_category: &ActualbudgetCategory, + actualbudget_zero_budgets: &[ActualbudgetZeroBudget] +) -> i32 { + let latest_zero_budget_for_category = actualbudget_zero_budgets + .iter() + .filter(|zb| zb.category_id == actualbudget_category.id) + .max_by_key(|zb| zb.month.clone()); + + match latest_zero_budget_for_category { + Some(latest_zero_budget_for_category) => latest_zero_budget_for_category.amount, + None => 0, + } +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::{actualbudget_category::make_fake_actualbudget_category_builder, actualbudget_zero_budget::make_fake_actualbudget_zero_budget_builder}; + use actualbudget_models::ActualbudgetDate; + use schist_models::{budget_period_unit::BudgetPeriodUnit, date_utc::DateUtc}; + use schist_traits::nowlike::Nowlike; + + use crate::transform_state::transform_id::transform_id; + + use super::transform_categories; + + #[test] + fn when_one_category_one_zero_budget_then_transforms_category() { + let actualbudget_categories = [ + make_fake_actualbudget_category_builder("0").name(String::from("Food")).build().unwrap(), + ]; + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("0").amount(100).build().unwrap(), + ]; + let before_first_transaction = DateUtc::now(); + + let categories = transform_categories( + &actualbudget_categories, + &actualbudget_zero_budgets, + &before_first_transaction, + ); + + assert_eq!(1, categories.len()); + let category = categories.get(0).unwrap(); + assert_eq!("Food", category.name); + assert_eq!(0, category.balance); + assert_eq!(before_first_transaction, category.balance_date); + assert_eq!(1, category.budget_period); + assert_eq!(BudgetPeriodUnit::Month, category.budget_period_unit); + assert_eq!(100, category.budget_quantity); + } + + #[test] + fn when_one_category_no_zero_budget_then_transforms_category() { + let actualbudget_categories = [ + make_fake_actualbudget_category_builder("0").name(String::from("Food")).build().unwrap(), + ]; + let actualbudget_zero_budgets = []; + let before_first_transaction = DateUtc::now(); + + let categories = transform_categories( + &actualbudget_categories, + &actualbudget_zero_budgets, + &before_first_transaction, + ); + + assert_eq!(1, categories.len()); + let category = categories.get(0).unwrap(); + assert_eq!("Food", category.name); + assert_eq!(0, category.balance); + assert_eq!(before_first_transaction, category.balance_date); + assert_eq!(1, category.budget_period); + assert_eq!(BudgetPeriodUnit::Month, category.budget_period_unit); + assert_eq!(0, category.budget_quantity); + } + + #[test] + fn when_one_category_multiple_zero_budgets_then_transforms_category() { + let actualbudget_categories = [ + make_fake_actualbudget_category_builder("0").name(String::from("Food")).build().unwrap(), + ]; + let jan = ActualbudgetDate::from_i32(202401); + let feb = ActualbudgetDate::from_i32(202402); + let mar = ActualbudgetDate::from_i32(202403); + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("0").amount(100).month(jan).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("1").amount(200).month(feb).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("2").amount(300).month(mar).build().unwrap(), + ]; + let before_first_transaction = DateUtc::now(); + + let categories = transform_categories( + &actualbudget_categories, + &actualbudget_zero_budgets, + &before_first_transaction, + ); + + assert_eq!(1, categories.len()); + let category = categories.get(0).unwrap(); + assert_eq!("Food", category.name); + assert_eq!(0, category.balance); + assert_eq!(before_first_transaction, category.balance_date); + assert_eq!(1, category.budget_period); + assert_eq!(BudgetPeriodUnit::Month, category.budget_period_unit); + assert_eq!(300, category.budget_quantity); + } + + #[test] + fn when_multiple_categories_multiple_zero_budgets_then_transforms_category() { + let actualbudget_categories = [ + make_fake_actualbudget_category_builder("0").name(String::from("Food")).build().unwrap(), + make_fake_actualbudget_category_builder("1").name(String::from("Clothes")).build().unwrap(), + make_fake_actualbudget_category_builder("2").name(String::from("Rent")).build().unwrap(), + ]; + let jan = ActualbudgetDate::from_i32(202401); + let feb = ActualbudgetDate::from_i32(202402); + let mar = ActualbudgetDate::from_i32(202403); + let actualbudget_zero_budgets = [ + make_fake_actualbudget_zero_budget_builder("0").amount(100).month(jan.clone()).category_id(String::from("0")).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("1").amount(200).month(feb.clone()).category_id(String::from("0")).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("2").amount(300).month(mar.clone()).category_id(String::from("0")).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("3").amount(110).month(jan.clone()).category_id(String::from("1")).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("4").amount(210).month(feb.clone()).category_id(String::from("1")).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("5").amount(310).month(mar.clone()).category_id(String::from("1")).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("6").amount(120).month(jan.clone()).category_id(String::from("2")).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("7").amount(220).month(feb.clone()).category_id(String::from("2")).build().unwrap(), + make_fake_actualbudget_zero_budget_builder("8").amount(320).month(mar.clone()).category_id(String::from("2")).build().unwrap(), + ]; + let before_first_transaction = DateUtc::now(); + + let categories = transform_categories( + &actualbudget_categories, + &actualbudget_zero_budgets, + &before_first_transaction, + ); + + assert_eq!(3, categories.len()); + + let category_0 = categories.iter().filter(|c| c.id == transform_id("0")).last().unwrap(); + assert_eq!("Food", category_0.name); + assert_eq!(0, category_0.balance); + assert_eq!(before_first_transaction, category_0.balance_date); + assert_eq!(1, category_0.budget_period); + assert_eq!(BudgetPeriodUnit::Month, category_0.budget_period_unit); + assert_eq!(300, category_0.budget_quantity); + + let category_1 = categories.iter().filter(|c| c.id == transform_id("1")).last().unwrap(); + assert_eq!("Clothes", category_1.name); + assert_eq!(0, category_1.balance); + assert_eq!(before_first_transaction, category_1.balance_date); + assert_eq!(1, category_1.budget_period); + assert_eq!(BudgetPeriodUnit::Month, category_1.budget_period_unit); + assert_eq!(310, category_1.budget_quantity); + + let category_2 = categories.iter().filter(|c| c.id == transform_id("2")).last().unwrap(); + assert_eq!("Rent", category_2.name); + assert_eq!(0, category_2.balance); + assert_eq!(before_first_transaction, category_2.balance_date); + assert_eq!(1, category_2.budget_period); + assert_eq!(BudgetPeriodUnit::Month, category_2.budget_period_unit); + assert_eq!(320, category_2.budget_quantity); + } +} diff --git a/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs b/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs new file mode 100644 index 0000000..318e88c --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs @@ -0,0 +1,346 @@ +use actualbudget_models::{ + ActualbudgetAccount, + ActualbudgetTransaction, +}; +use anyhow::{bail, Context, Result}; +use itertools::Itertools; +use schist_models::category_transfer::CategoryTransfer; + +use super::{is_category_transfer, transform_id}; + +pub fn transform_category_transfers( + actualbudget_transactions: &[ActualbudgetTransaction], + actualbudget_category_transfers_account: &Option<ActualbudgetAccount>, +) -> Result<Vec<CategoryTransfer>> { + let actualbudget_category_transfers = actualbudget_transactions + .iter() + .filter(|transaction| + is_category_transfer(transaction, actualbudget_category_transfers_account) + ); + 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(); + + Ok(actualbudget_parent_category_transfers + .iter() + .map(|pct| transform_category_transfer(pct, &actualbudget_child_category_transfers)) + .filter_ok(Option::is_some) + .map_ok(Option::unwrap) + .collect::<Result<Vec<CategoryTransfer>>>() + .with_context(|| "failed to transform category transfers")? + ) +} + +fn transform_category_transfer( + actualbudget_parent_category_transfer: &ActualbudgetTransaction, + actualbudget_child_category_transfers: &[ActualbudgetTransaction], +) -> Result<Option<CategoryTransfer>> { + 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<CategoryTransfer>> { + 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 from_transfers: Vec<ActualbudgetTransaction> = actualbudget_child_category_transfers + .clone() + .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 + .clone() + .filter(|t| t.amount > 0) + .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(CategoryTransfer { + id: transform_id(&actualbudget_parent_category_transfer.id), + description: actualbudget_parent_category_transfer + .notes + .clone() + .unwrap_or_else(String::new), + quantity: to_transfer.amount.try_into().unwrap(), + from_category_id, + to_category_id, + })) +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::{actualbudget_account::make_fake_actualbudget_account, actualbudget_transaction::make_fake_actualbudget_transaction_builder}; + use actualbudget_models::{ActualbudgetTransaction, ActualbudgetTransactionBuilder}; + use schist_models::category_transfer::CategoryTransfer; + + use crate::transform_state::transform_id::transform_id; + + use super::transform_category_transfers; + + #[test] + fn when_one_category_transfer_then_transforms() { + let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); + 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_category_transfers( + &actualbudget_transactions, + &Some(actualbudget_category_transfers_account), + ); + + assert!(result.is_ok()); + let category_transfers = result.unwrap(); + assert_eq!(1, category_transfers.len()); + let category_transfer = category_transfers.get(0).unwrap(); + assert_eq!(100, category_transfer.quantity); + assert_eq!("I was a parent transaction", category_transfer.description); + assert_eq!(transform_id("0"), category_transfer.from_category_id); + assert_eq!(transform_id("1"), category_transfer.to_category_id); + } + + #[test] + fn when_many_transactions_then_transforms() { + let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); + 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_category_transfers( + &actualbudget_transactions, + &Some(actualbudget_category_transfers_account), + ); + + assert!(result.is_ok()); + let category_transfers = result.unwrap(); + assert_eq!(2, category_transfers.len()); + + let category_transfer_0: Vec<&CategoryTransfer> = category_transfers + .iter() + .filter(|ct| ct.quantity == 100) + .collect(); + assert_eq!(1, category_transfer_0.len()); + let category_transfer_0 = category_transfer_0.get(0).unwrap(); + assert_eq!(transform_id("0"), category_transfer_0.from_category_id); + assert_eq!(transform_id("1"), category_transfer_0.to_category_id); + + let category_transfer_1: Vec<&CategoryTransfer> = category_transfers + .iter() + .filter(|ct| ct.quantity == 200) + .collect(); + assert_eq!(1, category_transfer_1.len()); + let category_transfer_1 = category_transfer_1.get(0).unwrap(); + assert_eq!(transform_id("1"), category_transfer_1.from_category_id); + assert_eq!(transform_id("0"), category_transfer_1.to_category_id); + } + + #[test] + fn when_multiple_child_from_transactions_then_err() { + let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); + 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_category_transfers( + &actualbudget_transactions, + &Some(actualbudget_category_transfers_account), + ); + + assert!(result.is_err()); + } + + #[test] + fn when_multiple_child_to_transactions_then_err() { + let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); + 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_category_transfers( + &actualbudget_transactions, + &Some(actualbudget_category_transfers_account), + ); + + assert!(result.is_err()); + } + + #[test] + fn when_no_child_from_transactions_then_err() { + let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_to_child_transaction("1", "0"), + ]; + + let result = transform_category_transfers( + &actualbudget_transactions, + &Some(actualbudget_category_transfers_account), + ); + + assert!(result.is_err()); + } + + #[test] + fn when_no_child_to_transactions_then_err() { + let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); + let actualbudget_transactions = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction("1", "0"), + ]; + + let result = transform_category_transfers( + &actualbudget_transactions, + &Some(actualbudget_category_transfers_account), + ); + + assert!(result.is_err()); + } + + #[test] + fn when_child_amounts_dont_add_up_to_zero_then_err() { + let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); + 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_category_transfers( + &actualbudget_transactions, + &Some(actualbudget_category_transfers_account), + ); + + assert!(result.is_err()); + } + + #[test] + fn when_child_category_transfer_has_no_category_then_err() { + let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); + 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_category_transfers( + &actualbudget_transactions_0, + &Some(actualbudget_category_transfers_account.clone()), + ); + let result_1 = transform_category_transfers( + &actualbudget_transactions_1, + &Some(actualbudget_category_transfers_account), + ); + + 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/actualbudget_to_schist_transformer/src/transform_state/transform_date.rs b/actualbudget_to_schist_transformer/src/transform_state/transform_date.rs new file mode 100644 index 0000000..57adae0 --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/transform_date.rs @@ -0,0 +1,31 @@ +use actualbudget_models::ActualbudgetDate; +use anyhow::Result; +use schist_models::date_utc::DateUtc; + +pub fn transform_date(actualbudget_date: &ActualbudgetDate) -> Result<DateUtc> { + actualbudget_date.to_iso_date().parse() +} + +#[cfg(test)] +mod test { + use actualbudget_models::ActualbudgetDate; + use schist_models::date_utc::DateUtc; + + use super::transform_date; + + #[test] + fn when_utc_epoch_as_day_then_utc_epoch() { + let actualbudget_date = ActualbudgetDate::from_i32(19700101); + let date_utc = transform_date(&actualbudget_date).unwrap(); + let expected_date_utc: DateUtc = "1970-01-01".parse().unwrap(); + assert_eq!(expected_date_utc, date_utc); + } + + #[test] + fn when_utc_epoch_as_month_then_utc_epoch() { + let actualbudget_date = ActualbudgetDate::from_i32(197001); + let date_utc = transform_date(&actualbudget_date).unwrap(); + let expected_date_utc: DateUtc = "1970-01-01".parse().unwrap(); + assert_eq!(expected_date_utc, date_utc); + } +} diff --git a/actualbudget_to_schist_transformer/src/transform_state/transform_id.rs b/actualbudget_to_schist_transformer/src/transform_state/transform_id.rs new file mode 100644 index 0000000..2d2f3af --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/transform_id.rs @@ -0,0 +1,26 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +pub fn transform_id(actualbudget_id: &str) -> i32 { + let mut hasher = DefaultHasher::new(); + actualbudget_id.hash(&mut hasher); + (hasher.finish() % std::convert::TryInto::<u64>::try_into(i32::MAX).unwrap()) + .try_into() + .unwrap() +} + +#[cfg(test)] +mod test { + use crate::test_resources::RANDOM_UUIDS; + + use super::transform_id; + + #[test] + fn when_transform_many_random_uuids_then_no_repeats() { + let mut results_so_far: Vec<i32> = Vec::new(); + for &uuid in RANDOM_UUIDS { + let transformed_uuid = transform_id(uuid); + assert!(!results_so_far.contains(&transformed_uuid)); + results_so_far.push(transformed_uuid); + } + } +} diff --git a/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs b/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs new file mode 100644 index 0000000..7c64e35 --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs @@ -0,0 +1,64 @@ +use anyhow::{bail, Context, Result}; +use schist_models::{account::Account, category::Category, category_transfer::CategoryTransfer, date_utc::DateUtc, transaction::Transaction, transaction_categorisation::TransactionCategorisation}; + +use crate::{actualbudget_state::ActualbudgetState, schist_state::SchistState}; + +use super::{transform_account_transfers, transform_accounts, transform_budget_drips::transform_budget_drips, transform_categories, transform_category_transfers, transform_date, transform_transactions}; + +pub fn transform_state(actualbudget_state: ActualbudgetState) -> Result<SchistState> { + transform_state_without_context(actualbudget_state) + .with_context(|| "failed to transform actualbudget state to schist state") +} + +fn transform_state_without_context(actualbudget_state: ActualbudgetState) -> Result<SchistState> { + let (remaining_transactions, account_transfers) = transform_account_transfers( + &actualbudget_state.actualbudget_transactions, + )?; + let first_activity_date = + transform_date(&actualbudget_state.actualbudget_first_activity_date)?; + let before_first_activity_date = find_date_before(&first_activity_date)?; + let accounts: Vec<Account> = + transform_accounts( + &actualbudget_state.actualbudget_accounts, + &actualbudget_state.actualbudget_category_transfers_account, + &first_activity_date, + ); + let budget_drips = transform_budget_drips(&actualbudget_state.actualbudget_zero_budgets)?; + let categories: Vec<Category> = transform_categories( + &actualbudget_state.actualbudget_categories, + &actualbudget_state.actualbudget_zero_budgets, + &before_first_activity_date, + ); + let (transactions, transaction_categorisations): (Vec<Transaction>, Vec<TransactionCategorisation>) = + transform_transactions( + &remaining_transactions, + &actualbudget_state.actualbudget_category_transfers_account, + &actualbudget_state.actualbudget_payees, + )?; + let category_transfers: Vec<CategoryTransfer> = transform_category_transfers( + &remaining_transactions, + &actualbudget_state.actualbudget_category_transfers_account, + )?; + + Ok(SchistState { + first_transaction_date: first_activity_date, + accounts, + account_transfers, + categories, + budget_drips, + transactions, + transaction_categorisations, + category_transfers, + }) +} + +fn find_date_before(date: &DateUtc) -> Result<DateUtc> { + for days_before in 1..31 { + let result = date.sub_days(days_before); + if result.is_ok() { + return result; + } + } + + bail!("failed to find a date before {}", date) +} diff --git a/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs b/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs new file mode 100644 index 0000000..f7fb098 --- /dev/null +++ b/actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs @@ -0,0 +1,253 @@ +use anyhow::{Context, Result}; +use actualbudget_models::{ActualbudgetAccount, ActualbudgetPayee, ActualbudgetTransaction}; +use schist_models::{transaction::Transaction, transaction_categorisation::TransactionCategorisation}; + +use super::{is_not_category_transfer, transform_date, transform_id}; + +pub fn transform_transactions( + actualbudget_transactions: &[ActualbudgetTransaction], + actualbudget_category_transfers_account: &Option<ActualbudgetAccount>, + actualbudget_payees: &[ActualbudgetPayee], +) -> Result<(Vec<Transaction>, Vec<TransactionCategorisation>)> { + transform_transactions_without_context( + actualbudget_transactions, + actualbudget_category_transfers_account, + actualbudget_payees, + ) + .with_context(|| "failed to transform actualbudget transactions to schist transactions") +} + +fn transform_transactions_without_context( + actualbudget_transactions: &[ActualbudgetTransaction], + actualbudget_category_transfers_account: &Option<ActualbudgetAccount>, + actualbudget_payees: &[ActualbudgetPayee], +) -> Result<(Vec<Transaction>, Vec<TransactionCategorisation>)> { + let mut transactions = Vec::<Transaction>::new(); + let mut transaction_categorisations = Vec::<TransactionCategorisation>::new(); + + let actualbudget_transactions = actualbudget_transactions + .iter() + .filter(|t| is_not_category_transfer(t, actualbudget_category_transfers_account)); + let parent_actualbudget_transactions: Vec<&ActualbudgetTransaction> = actualbudget_transactions + .clone() + .filter(|t| t.is_parent) + .collect(); + let child_actualbudget_transactions: Vec<&ActualbudgetTransaction> = actualbudget_transactions + .clone() + .filter(|t| t.is_child) + .collect(); + let atomic_actualbudget_transactions: Vec<&ActualbudgetTransaction> = actualbudget_transactions + .filter(|t| !t.is_parent && !t.is_child) + .collect(); + + for atomic_actualbudget_transaction in atomic_actualbudget_transactions { + let transaction_id = transform_id(&atomic_actualbudget_transaction.id); + + let payee_name = atomic_actualbudget_transaction + .payee + .clone() + .map(|p| actualbudget_payees + .iter() + .find(|pp| pp.id == p) + .map(|pp| pp.name.clone()) + ).unwrap_or(None) + .unwrap_or_else(String::new); + + transactions.push(Transaction { + id: transaction_id, + description: atomic_actualbudget_transaction.notes.clone().unwrap_or_else(String::new), + payee: payee_name, + quantity: atomic_actualbudget_transaction.amount.try_into().unwrap(), + date: transform_date(&atomic_actualbudget_transaction.date)?, + account_id: transform_id(&atomic_actualbudget_transaction.account_id), + }); + + let actualbudget_category_id = atomic_actualbudget_transaction + .category_id + .clone() + .with_context(|| format!( + "atomic actualbudget transaction {} did not have a category ID", + atomic_actualbudget_transaction.id, + ))?; + + transaction_categorisations.push(TransactionCategorisation { + id: transaction_id, + description: String::new(), + quantity: atomic_actualbudget_transaction.amount.try_into().unwrap(), + transaction_id, + category_id: transform_id(&actualbudget_category_id), + }); + } + + for parent_actualbudget_transaction in parent_actualbudget_transactions { + let child_actualbudget_transactions = child_actualbudget_transactions + .iter() + .filter(|t| t + .parent_id + .clone() + .is_some_and(|parent_id| parent_id == parent_actualbudget_transaction.id.clone()) + ); + + let transaction_id = transform_id(&parent_actualbudget_transaction.id); + + transactions.push(Transaction { + id: transaction_id, + description: parent_actualbudget_transaction.notes.clone().unwrap_or_else(String::new), + payee: parent_actualbudget_transaction.payee.clone().unwrap_or_else(String::new), + quantity: parent_actualbudget_transaction.amount.try_into().unwrap(), + date: transform_date(&parent_actualbudget_transaction.date)?, + account_id: transform_id(&parent_actualbudget_transaction.account_id), + }); + + for child_actualbudget_transaction in child_actualbudget_transactions { + let actualbudget_category_id = child_actualbudget_transaction + .category_id + .clone() + .with_context(|| format!( + "actualbudget child transaction {} had no category ID", + child_actualbudget_transaction.id, + ))?; + + transaction_categorisations.push(TransactionCategorisation { + id: transform_id(&child_actualbudget_transaction.id), + description: child_actualbudget_transaction.notes.clone().unwrap_or_else(String::new), + quantity: child_actualbudget_transaction.amount.try_into().unwrap(), + transaction_id, + category_id: transform_id(&actualbudget_category_id), + }); + } + } + + Ok((transactions, transaction_categorisations)) +} + +#[cfg(test)] +mod test { + use actualbudget_fakes::{actualbudget_account::make_fake_actualbudget_account, actualbudget_payee::{make_fake_actualbudget_payee, make_fake_actualbudget_payee_builder}, actualbudget_transaction::make_fake_actualbudget_transaction_builder}; + use actualbudget_models::ActualbudgetDate; + use schist_models::date_utc::DateUtc; + + use crate::transform_state::transform_id::transform_id; + + use super::transform_transactions; + + #[test] + fn when_one_category_transfer_transaction_then_returns_empty() { + let actualbudget_transactions = [ + make_fake_actualbudget_transaction_builder("0") + .category_id(Some(String::from("0"))) + .build() + .unwrap(), + ]; + let actualbudget_category_transfers_account = make_fake_actualbudget_account("0"); + let actualbudget_payees = [make_fake_actualbudget_payee("0")]; + + let (transactions, transaction_categorisations) = transform_transactions( + &actualbudget_transactions, + &Some(actualbudget_category_transfers_account), + &actualbudget_payees, + ).unwrap(); + + assert_eq!(0, transactions.len()); + assert_eq!(0, transaction_categorisations.len()); + } + + #[test] + fn when_one_atomic_transaction_then_returns_transformed_transaction() { + let actualbudget_transactions = [ + make_fake_actualbudget_transaction_builder("0") + .account_id(String::from("0")) + .amount(100) + .category_id(Some(String::from("0"))) + .payee(Some(String::from("0"))) + .notes(Some(String::from("Savings"))) + .date(ActualbudgetDate::from_i32(19700101)) + .build() + .unwrap(), + ]; + let actualbudget_payees = [ + make_fake_actualbudget_payee_builder("0") + .name(String::from("Gringotts")) + .build() + .unwrap(), + ]; + + let (transactions, transaction_categorisations) = transform_transactions( + &actualbudget_transactions, + &None, + &actualbudget_payees, + ).unwrap(); + + assert_eq!(1, transactions.len()); + let transaction = transactions.get(0).unwrap(); + assert_eq!("Savings", transaction.description); + assert_eq!("Gringotts", transaction.payee); + assert_eq!(100, transaction.quantity); + assert_eq!(DateUtc::from_ymd(1970, 1, 1).unwrap(), transaction.date); + assert_eq!(transform_id("0"), transaction.account_id); + + assert_eq!(1, transaction_categorisations.len()); + let transaction_categorisation = transaction_categorisations.get(0).unwrap(); + assert_eq!("", transaction_categorisation.description); + assert_eq!(100, transaction_categorisation.quantity); + assert_eq!(transaction.id, transaction_categorisation.transaction_id); + assert_eq!(transform_id("0"), transaction_categorisation.category_id); + } + + #[test] + fn when_one_split_transaction_then_returns_transformed_transaction() { + let actualbudget_transactions = [ + make_fake_actualbudget_transaction_builder("0") + .is_parent(true) + .amount(100) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("1") + .amount(200) + .category_id(Some(String::from("0"))) + .is_child(true) + .parent_id(Some(String::from("0"))) + .notes(Some(String::from("notes A"))) + .build() + .unwrap(), + make_fake_actualbudget_transaction_builder("2") + .amount(300) + .category_id(Some(String::from("0"))) + .is_child(true) + .parent_id(Some(String::from("0"))) + .notes(Some(String::from("notes B"))) + .build() + .unwrap(), + ]; + + let (transactions, transaction_categorisations) = transform_transactions( + &actualbudget_transactions, + &None, + &[], + ).unwrap(); + + assert_eq!(1, transactions.len()); + assert_eq!(2, transaction_categorisations.len()); + + let transaction = transactions.get(0).unwrap(); + assert_eq!(100, transaction.quantity); + + let transaction_categorisation_0 = transaction_categorisations + .iter() + .filter(|tc| tc.quantity == 200) + .last() + .unwrap(); + assert_eq!(transaction.id, transaction_categorisation_0.transaction_id); + assert_eq!(transform_id("0"), transaction_categorisation_0.category_id); + assert_eq!("notes A", transaction_categorisation_0.description); + + let transaction_categorisation_1 = transaction_categorisations + .iter() + .filter(|tc| tc.quantity == 300) + .last() + .unwrap(); + assert_eq!(transaction.id, transaction_categorisation_1.transaction_id); + assert_eq!(transform_id("0"), transaction_categorisation_1.category_id); + assert_eq!("notes B", transaction_categorisation_1.description); + } +} |
