summaryrefslogtreecommitdiff
path: root/rust/schist_actualbudget_importer
diff options
context:
space:
mode:
authorJoe Carstairs <me@joeac.net>2024-11-24 22:53:10 +0000
committerJoe Carstairs <me@joeac.net>2024-11-24 23:16:05 +0000
commit06770a965f75416c22ef7115004a250388cebcbf (patch)
treef4125385fdd50d2014bd8f7c2a44d7c462b8c3be /rust/schist_actualbudget_importer
parent366f3e98bafaa8a6d364d62496ab7139935bfdec (diff)
transform budget drips
Diffstat (limited to 'rust/schist_actualbudget_importer')
-rw-r--r--rust/schist_actualbudget_importer/Cargo.toml3
-rw-r--r--rust/schist_actualbudget_importer/src/transform_state/transform_accounts.rs8
-rw-r--r--rust/schist_actualbudget_importer/src/transform_state/transform_budget_drips.rs285
-rw-r--r--rust/schist_actualbudget_importer/src/transform_state/transform_state.rs2
4 files changed, 285 insertions, 13 deletions
diff --git a/rust/schist_actualbudget_importer/Cargo.toml b/rust/schist_actualbudget_importer/Cargo.toml
index 3fa308a..a4bfbd1 100644
--- a/rust/schist_actualbudget_importer/Cargo.toml
+++ b/rust/schist_actualbudget_importer/Cargo.toml
@@ -4,10 +4,13 @@ version = "0.1.0"
edition = "2021"
[dependencies]
+actualbudget_fakes = { path = "../actualbudget_fakes" }
actualbudget_models = { path = "../actualbudget_models" }
actualbudget_queries = { path = "../actualbudget_queries" }
anyhow = { workspace = true }
diesel = { workspace = true, features = ["sqlite"] }
+itertools = { workspace = true }
libsqlite3-sys = { workspace = true, features = ["bundled"] }
schist_models = { path = "../schist_models" }
schist_queries = { path = "../schist_queries" }
+schist_traits = { path = "../schist_traits" }
diff --git a/rust/schist_actualbudget_importer/src/transform_state/transform_accounts.rs b/rust/schist_actualbudget_importer/src/transform_state/transform_accounts.rs
index c100377..917c62d 100644
--- a/rust/schist_actualbudget_importer/src/transform_state/transform_accounts.rs
+++ b/rust/schist_actualbudget_importer/src/transform_state/transform_accounts.rs
@@ -25,11 +25,3 @@ fn transform_account(
opening_date: opening_date.clone(),
}
}
-
-#[cfg(test)]
-mod test {
- #[test]
- fn todo() {
- todo!()
- }
-}
diff --git a/rust/schist_actualbudget_importer/src/transform_state/transform_budget_drips.rs b/rust/schist_actualbudget_importer/src/transform_state/transform_budget_drips.rs
index 418c759..2e9125b 100644
--- a/rust/schist_actualbudget_importer/src/transform_state/transform_budget_drips.rs
+++ b/rust/schist_actualbudget_importer/src/transform_state/transform_budget_drips.rs
@@ -1,6 +1,283 @@
-use anyhow::Result;
-use schist_models::budget_drip::BudgetDrip;
+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;
-pub fn transform_budget_drips() -> Result<Vec<BudgetDrip>> {
- todo!();
+use super::{transform_date::transform_date, transform_id::transform_id};
+
+pub fn transform_budget_drips(actualbudget_zero_budgets: &[ActualbudgetZeroBudget]) -> Result<Vec<BudgetDrip>> {
+ let budget_drips = group_actualbudget_zero_budgets_by_category_id(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()
+ .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_id(&actualbudget_zero_budget.id),
+ 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()
+}
+
+#[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/rust/schist_actualbudget_importer/src/transform_state/transform_state.rs b/rust/schist_actualbudget_importer/src/transform_state/transform_state.rs
index d8918bc..2285276 100644
--- a/rust/schist_actualbudget_importer/src/transform_state/transform_state.rs
+++ b/rust/schist_actualbudget_importer/src/transform_state/transform_state.rs
@@ -10,7 +10,7 @@ pub fn transform_state(actualbudget_state: ActualbudgetState) -> Result<SchistSt
transform_date(&actualbudget_state.actualbudget_first_transaction_date)?;
let accounts: Vec<Account> =
transform_accounts(&actualbudget_state.actualbudget_accounts, &first_transaction_date);
- let budget_drips = transform_budget_drips()?;
+ let budget_drips = transform_budget_drips(&actualbudget_state.actualbudget_zero_budgets)?;
let categories: Vec<Category> =
transform_categories(&actualbudget_state.actualbudget_categories);
let (transactions, transaction_categorisations): (Vec<Transaction>, Vec<TransactionCategorisation>) =