summaryrefslogtreecommitdiff
path: root/actualbudget_to_schist_transformer/src/transform_state
diff options
context:
space:
mode:
Diffstat (limited to 'actualbudget_to_schist_transformer/src/transform_state')
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/is_account_transfer.rs13
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/is_category_transfer.rs18
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/mod.rs25
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/transform_account_transfers.rs306
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/transform_accounts.rs30
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/transform_budget_drips.rs306
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/transform_categories.rs204
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/transform_category_transfers.rs346
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/transform_date.rs31
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/transform_id.rs26
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/transform_state.rs64
-rw-r--r--actualbudget_to_schist_transformer/src/transform_state/transform_transactions.rs253
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);
+ }
+}