summaryrefslogtreecommitdiff
path: root/transformers
diff options
context:
space:
mode:
authorJoe Carstairs <me@joeac.net>2026-01-17 09:24:08 +0000
committerJoe Carstairs <me@joeac.net>2026-01-17 16:44:23 +0000
commit9d0add85692402afd328b9a81e42b22fe4da5db1 (patch)
tree1c2c15e23252b3d637394b6cbc72aa32119b05f3 /transformers
parentf1da16e460fe21581837c9385c1ca759ac55060c (diff)
Account for zero budgets not carried over
when transforming bucket transactions from Actualbudget
Diffstat (limited to 'transformers')
-rw-r--r--transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state.rs2
-rw-r--r--transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_bucket_transactions_from_budgets_not_carried_over.rs350
-rw-r--r--transformers/actualbudget_transformer/actualbudget_to_schist_transformer/src/transform_state/transform_state.rs22
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,