diff options
Diffstat (limited to 'rust')
3 files changed, 327 insertions, 54 deletions
diff --git a/rust/actualbudget_models/src/actualbudget_transaction.rs b/rust/actualbudget_models/src/actualbudget_transaction.rs index 950ea07..dc64de0 100644 --- a/rust/actualbudget_models/src/actualbudget_transaction.rs +++ b/rust/actualbudget_models/src/actualbudget_transaction.rs @@ -3,7 +3,7 @@ use diesel::prelude::{Identifiable, Queryable, QueryableByName, Selectable}; use crate::actualbudget_date::ActualbudgetDate; -#[derive(Builder, Identifiable, QueryableByName, Queryable, Selectable, Clone)] +#[derive(Builder, Debug, Identifiable, QueryableByName, Queryable, Selectable, Clone)] #[diesel(table_name = crate::actualbudget_schema::v_transactions)] pub struct ActualbudgetTransaction { pub id: String, diff --git a/rust/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs b/rust/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs index 14b34f2..2bcd0e0 100644 --- a/rust/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs +++ b/rust/actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs @@ -2,72 +2,345 @@ use actualbudget_models::{ ActualbudgetAccount, ActualbudgetTransaction, }; +use anyhow::{bail, Context, Result}; +use itertools::Itertools; use schist_models::category_transfer::CategoryTransfer; -use core::panic; use super::{is_category_transfer, transform_id}; pub fn transform_category_transfers( actualbudget_transactions: &[ActualbudgetTransaction], actualbudget_category_transfers_account: &Option<ActualbudgetAccount>, -) -> Vec<CategoryTransfer> { - let mut category_transfers = Vec::<CategoryTransfer>::new(); - - let mut actualbudget_category_transfers = actualbudget_transactions.to_vec(); - actualbudget_category_transfers.retain(|transaction| - is_category_transfer(transaction, actualbudget_category_transfers_account) - ); - let mut actualbudget_parent_category_transfers = actualbudget_category_transfers.to_vec(); - actualbudget_parent_category_transfers.retain(|t| t.is_parent); - let mut actualbudget_child_category_transfers = actualbudget_category_transfers.to_vec(); - actualbudget_child_category_transfers.retain(|t| t.is_child); - - for actualbudget_parent_category_transfer in actualbudget_parent_category_transfers { - let mut actualbudget_child_category_transfers = - actualbudget_child_category_transfers.to_vec(); - actualbudget_child_category_transfers.retain(|t| { +) -> 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) - }); - - if actualbudget_child_category_transfers.len() != 2 { - panic!("Category transfers should always have exactly two children."); - } - - let category_1 = actualbudget_child_category_transfers[0].clone(); - let category_2 = actualbudget_child_category_transfers[1].clone(); - - // To avoid duplicates - if category_1.amount >= 0 { - continue; - } - - if actualbudget_child_category_transfers[0].amount - != -actualbudget_child_category_transfers[1].amount - { - panic!("Category transfer children should sum to zero."); - } - - category_transfers.push(CategoryTransfer { - id: transform_id(&actualbudget_parent_category_transfer.id), - description: actualbudget_parent_category_transfer.notes.unwrap_or_else(String::new), - quantity: actualbudget_child_category_transfers[0] - .amount - .try_into() - .unwrap(), - from_category_id: transform_id(&category_1.id), - to_category_id: transform_id(&category_2.id), - }); - } - - category_transfers + ).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: from_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 todo() { - todo!() + 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", "0").amount(100).category_id(Some(String::from("0"))).build().unwrap(), + make_fake_to_child_transaction_builder("2", "0").amount(-100).category_id(Some(String::from("1"))).build().unwrap(), + make_fake_parent_transaction("3"), + make_fake_from_child_transaction_builder("4", "3").amount(200).category_id(Some(String::from("1"))).build().unwrap(), + make_fake_to_child_transaction_builder("5", "3").amount(-200).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", "0").amount(50).build().unwrap(), + make_fake_from_child_transaction_builder("2", "0").amount(50).build().unwrap(), + make_fake_to_child_transaction_builder("3", "0").amount(-100).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", "0").amount(100).build().unwrap(), + make_fake_to_child_transaction_builder("2", "0").amount(-50).build().unwrap(), + make_fake_to_child_transaction_builder("3", "0").amount(-50).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", "0").amount(50).build().unwrap(), + make_fake_to_child_transaction_builder("2", "0").amount(-100).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", "0").category_id(None).build().unwrap(), + make_fake_to_child_transaction_builder("2", "0").build().unwrap(), + ]; + let actualbudget_transactions_1 = [ + make_fake_parent_transaction("0"), + make_fake_from_child_transaction_builder("1", "0").build().unwrap(), + make_fake_to_child_transaction_builder("2", "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, parent_id).build().unwrap() + } + fn make_fake_from_child_transaction_builder(id: &str, 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(100) + .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, parent_id).build().unwrap() + } + + fn make_fake_to_child_transaction_builder(id: &str, 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(-100) + .is_child(true) + .notes(Some(String::from("I was a to child transaction"))) + .parent_id(Some(String::from(parent_id))); + builder } } diff --git a/rust/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs b/rust/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs index cc5dfe1..9fae7a1 100644 --- a/rust/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs +++ b/rust/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs @@ -25,7 +25,7 @@ pub fn transform_state(actualbudget_state: ActualbudgetState) -> Result<SchistSt let category_transfers: Vec<CategoryTransfer> = transform_category_transfers( &actualbudget_state.actualbudget_transactions, &actualbudget_state.actualbudget_category_transfers_account, - ); + )?; Ok(SchistState { first_transaction_date, |
