diff options
| author | Joe Carstairs <me@joeac.net> | 2026-01-17 09:24:08 +0000 |
|---|---|---|
| committer | Joe Carstairs <me@joeac.net> | 2026-01-17 16:44:23 +0000 |
| commit | 9d0add85692402afd328b9a81e42b22fe4da5db1 (patch) | |
| tree | 1c2c15e23252b3d637394b6cbc72aa32119b05f3 /transformers | |
| parent | f1da16e460fe21581837c9385c1ca759ac55060c (diff) | |
Account for zero budgets not carried over
when transforming bucket transactions from Actualbudget
Diffstat (limited to 'transformers')
3 files changed, 366 insertions, 8 deletions
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 ae560c6..6101403 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 @@ -2,6 +2,7 @@ mod is_account_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; @@ -17,6 +18,7 @@ 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; 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..6001153 --- /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::bucket_transaction::BucketTransaction; +use schist_traits::dateable::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, + quantity: -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::{bucket_transaction::BucketTransaction, date_utc::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.quantity, 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.quantity == 2); + assert!(transaction.is_some()); + let transaction = transaction.unwrap(); + assert_eq!(transaction.quantity, 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.quantity == 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_state.rs b/transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs index d3ebee5..0d21a71 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,7 +1,10 @@ use anyhow::{Context, Result}; 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, @@ -37,13 +40,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_transactions: Vec<BucketTransaction> = - vec![transform_bucket_transactions_from_category_transfers( - &remaining_transactions, - )?] - .into_iter() - .flatten() - .collect(); + 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, |
