diff options
| -rw-r--r-- | rust/Cargo.lock | 12 | ||||
| -rw-r--r-- | rust/Cargo.toml | 1 | ||||
| -rw-r--r-- | rust/actualbudget_models/src/actualbudget_date.rs | 2 | ||||
| -rw-r--r-- | rust/schist_actualbudget_importer/Cargo.toml | 3 | ||||
| -rw-r--r-- | rust/schist_actualbudget_importer/src/transform_state/transform_accounts.rs | 8 | ||||
| -rw-r--r-- | rust/schist_actualbudget_importer/src/transform_state/transform_budget_drips.rs | 285 | ||||
| -rw-r--r-- | rust/schist_actualbudget_importer/src/transform_state/transform_state.rs | 2 | ||||
| -rw-r--r-- | rust/schist_models/src/budget_drip.rs | 2 | ||||
| -rw-r--r-- | rust/schist_models/src/date_utc.rs | 65 |
9 files changed, 365 insertions, 15 deletions
diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7b3bef6..ea2b78a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -731,6 +731,15 @@ dependencies = [ ] [[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1324,13 +1333,16 @@ dependencies = [ name = "schist_actualbudget_importer" version = "0.1.0" dependencies = [ + "actualbudget_fakes", "actualbudget_models", "actualbudget_queries", "anyhow", "diesel", + "itertools", "libsqlite3-sys", "schist_models", "schist_queries", + "schist_traits", ] [[package]] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 89f1dfe..ee7450f 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -22,6 +22,7 @@ derive_builder = { version = "0.20.2" } diesel = { version = "2.2.0" } diesel_migrations = "2.2.0" dotenvy = "0.15.7" +itertools = "0.13.0" libsqlite3-sys = { version = "0.30.1" } rocket = { version = "0.5.1" } rocket_sync_db_pools = { version = "0.1.0" } diff --git a/rust/actualbudget_models/src/actualbudget_date.rs b/rust/actualbudget_models/src/actualbudget_date.rs index bef160c..c98accb 100644 --- a/rust/actualbudget_models/src/actualbudget_date.rs +++ b/rust/actualbudget_models/src/actualbudget_date.rs @@ -17,7 +17,7 @@ enum DatePart { impl ActualbudgetDate { pub fn to_iso(&self) -> String { - format!("{0}-{1}-{2}", self.year(), self.month(), self.day()) + format!("{0}-{1}-{2}T00:00:00.000Z", self.year(), self.month(), self.day()) } pub fn year(&self) -> String { 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>) = diff --git a/rust/schist_models/src/budget_drip.rs b/rust/schist_models/src/budget_drip.rs index f467ecd..2cc2c63 100644 --- a/rust/schist_models/src/budget_drip.rs +++ b/rust/schist_models/src/budget_drip.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::date_utc::DateUtc; -#[derive(Builder, Queryable, Identifiable, Selectable, Debug, PartialEq, Serialize, Deserialize, Insertable)] +#[derive(Builder, Clone, Copy, Queryable, Identifiable, Selectable, Debug, PartialEq, Serialize, Deserialize, Insertable)] #[diesel(table_name = crate::schema::budget_drips)] pub struct BudgetDrip { pub id: i32, diff --git a/rust/schist_models/src/date_utc.rs b/rust/schist_models/src/date_utc.rs index 3f5391c..c801585 100644 --- a/rust/schist_models/src/date_utc.rs +++ b/rust/schist_models/src/date_utc.rs @@ -24,6 +24,7 @@ use crate::datetime_utc::DatetimeUtc; Serialize, Deserialize, Clone, + Copy, Debug, AsExpression, FromSqlRow, @@ -55,6 +56,19 @@ impl DateUtc { .abs_diff(other.chrono_datetime_utc.num_days_from_ce()) } + pub fn days_in_month(&self) -> u32 { + self.first_day_in_next_month().day_diff(&self.first_day_in_month()) + } + + pub fn add_days(&self, days: u64) -> Result<Self> { + let chrono_datetime_utc = self + .chrono_datetime_utc + .checked_add_days(chrono::Days::new(days)) + .with_context(|| format!("failed to add {} days to {}", days, self)) + .unwrap(); + Ok(Self { chrono_datetime_utc }) + } + fn is_day_str(str: &str) -> bool { if str.len() != 10 { return false; @@ -72,6 +86,42 @@ impl DateUtc { } return true; } + + fn first_day_in_next_month(&self) -> Self { + let month = self.month(); + let mut curr_guess: Option<DateTime<Utc>> = None; + let mut curr_add = 1; + while curr_guess.is_none() || curr_guess.unwrap().month() == month { + curr_guess = self.chrono_datetime_utc.checked_add_days(chrono::Days::new(curr_add)); + curr_add = curr_add + 1; + }; + return Self { chrono_datetime_utc: curr_guess.unwrap() }; + } + + fn first_day_in_month(&self) -> Self { + let month = self.month(); + let mut prev_guess = self.clone(); + let mut curr_guess = self.clone(); + let mut curr_subtractor = 1; + while curr_guess.month() == month { + prev_guess = curr_guess.clone(); + + let mut next: Option<DateTime<Utc>> = None; + while next.is_none() { + if curr_subtractor > 31 { + return prev_guess; + } + next = self.chrono_datetime_utc.checked_sub_days(chrono::Days::new(curr_subtractor)); + curr_subtractor = curr_subtractor + 1; + } + let next = next.unwrap(); + + curr_guess = Self { + chrono_datetime_utc: next, + }; + }; + return prev_guess; + } } impl Nowlike for DateUtc { @@ -306,4 +356,19 @@ mod test { assert_eq!(day_diff, 1); assert_eq!(day_diff, neg_day_diff); } + + #[test] + fn test_first_day_in_month() { + todo!(); + } + + #[test] + fn test_first_day_in_next_month() { + todo!(); + } + + #[test] + fn test_days_in_month() { + todo!(); + } } |
