summaryrefslogtreecommitdiff
path: root/rust
diff options
context:
space:
mode:
Diffstat (limited to 'rust')
-rw-r--r--rust/Cargo.lock12
-rw-r--r--rust/Cargo.toml1
-rw-r--r--rust/actualbudget_models/src/actualbudget_date.rs2
-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
-rw-r--r--rust/schist_models/src/budget_drip.rs2
-rw-r--r--rust/schist_models/src/date_utc.rs65
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!();
+ }
}