From 408b0ac993496b108ec1e479151d549e9535051a Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Mon, 5 Oct 2020 21:20:59 +0300 Subject: initial commit --- budget/Cargo.toml | 12 ++++ budget/src/lib.rs | 162 +++++++++++++++++++++++++++++++++++++++++++++++++ budget/tests/budget.rs | 149 +++++++++++++++++++++++++++++++++++++++++++++ budget/tests/test.toml | 53 ++++++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 budget/Cargo.toml create mode 100644 budget/src/lib.rs create mode 100644 budget/tests/budget.rs create mode 100644 budget/tests/test.toml (limited to 'budget') diff --git a/budget/Cargo.toml b/budget/Cargo.toml new file mode 100644 index 0000000..4c1c63e --- /dev/null +++ b/budget/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "budget" +version = "0.1.0" +authors = ["Yaroslav "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +toml = "0.5" +chrono = "0.4" diff --git a/budget/src/lib.rs b/budget/src/lib.rs new file mode 100644 index 0000000..4dab46a --- /dev/null +++ b/budget/src/lib.rs @@ -0,0 +1,162 @@ +use std::collections::HashMap; +use std::io::ErrorKind; +use std::fs; + +use toml::de::Error as DeserializerError; +use serde::{Deserialize, Deserializer}; +use chrono::NaiveDate; + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Account { + #[serde(deserialize_with = "deserialize_date")] + pub start_date: NaiveDate, + #[serde(deserialize_with = "deserialize_date")] + pub end_date: NaiveDate, + pub budget: f64, + #[serde(default)] + pub essential_categories: Vec, + pub days: Vec, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Day { + #[serde(deserialize_with = "deserialize_date")] + pub date: NaiveDate, + pub expenses: Vec, +} + +#[derive(Deserialize, PartialEq, Debug)] +pub struct Expense { + pub name: String, + pub price: f64, + #[serde(default = "shared_qty_default")] + pub qty: u32, // unused for now, might use it the future or remove it + #[serde(default = "shared_qty_default")] + pub shared: u32, + #[serde(default = "recurring_default")] + pub recurring: bool, + #[serde(default)] + pub category: Option, +} + +#[derive(PartialEq, Debug)] +pub struct Calculated { + pub all_day_average: f64, + pub essential_day_average: f64, + pub categories_day_average: HashMap, + pub essential_subtotal: f64, + pub categories_subtotal: HashMap, + pub total: f64, + pub balance: f64, + pub days_left: f64, + pub days_left_essential: f64, +} + +#[derive(PartialEq, Eq, Debug)] +pub enum ParseError { + IOError(ErrorKind), + DeserializerError(DeserializerError), +} + +fn shared_qty_default() -> u32 { + 1 +} + +fn recurring_default() -> bool { + false +} + +// Parse the dates from toml's Datetime to Chrono's NaiveDate +// Probably unnecessary for now, but since I am planning on using the dates in +// the future to more easily count the days, it would be better to have them in +// a proper format +fn deserialize_date<'de, D>(deserializer: D) -> Result +where D: Deserializer<'de> { + toml::value::Datetime::deserialize(deserializer) + .map(|v| { + let s = v.to_string(); + + NaiveDate::parse_from_str(&s, "%Y-%m-%d") + })? + .map_err(serde::de::Error::custom) +} + +pub fn parse_account(path: &str) -> Result { + let contents = match fs::read_to_string(path) { + Ok(data) => data, + Err(error) => { + return Err(ParseError::IOError(error.kind())); + }, + }; + + match toml::from_str::(&contents) { + Ok(budget) => Ok(budget), + Err(error) => Err(ParseError::DeserializerError(error)), + } +} + +pub fn calculate(account: &Account) -> Calculated { + let mut calculated = Calculated { + all_day_average: 0.0, + essential_day_average: 0.0, + categories_day_average: HashMap::::new(), + essential_subtotal: 0.0, + categories_subtotal: HashMap::::new(), + total: 0.0, + balance: 0.0, + days_left: 0.0, + days_left_essential: 0.0, + }; + + let mut days_calculated = 0; + + for day in account.days.iter() { + days_calculated += 1; + + for expense in day.expenses.iter() { + calculated.total += expense.price; + + if let Some(category) = &expense.category { + if let Some(category_subtotal) = + calculated.categories_subtotal.get_mut(category) { + *category_subtotal += expense.price; + } else { + calculated.categories_subtotal.insert( + category.to_string(), + expense.price, + ); + } + + if account.essential_categories.contains(category) { + calculated.essential_subtotal += expense.price; + } + } + } + } + + calculated.all_day_average = calculated.total / days_calculated as f64; + calculated.essential_day_average = + calculated.essential_subtotal / days_calculated as f64; + + for (category, subtotal) in calculated.categories_subtotal.iter() { + calculated.categories_day_average + .insert( + category.clone(), + subtotal / days_calculated as f64, + ); + } + + calculated.balance = account.budget - calculated.total; + + calculated.days_left = calculated.balance / calculated.all_day_average; + calculated.days_left_essential = + calculated.balance / calculated.essential_day_average; + + calculated +} + +pub fn get_calculated(path: &str) -> Result { + let account = parse_account(path)?; + + Ok(calculate(&account)) +} diff --git a/budget/tests/budget.rs b/budget/tests/budget.rs new file mode 100644 index 0000000..32828f8 --- /dev/null +++ b/budget/tests/budget.rs @@ -0,0 +1,149 @@ +use std::collections::HashMap; + +use chrono::prelude::*; + +use budget::*; + +#[test] +fn can_parse_account() -> Result<(), ParseError>{ + let should_be = Account { + start_date: NaiveDate::from_ymd(2020, 10, 1), + end_date: NaiveDate::from_ymd(2020, 10, 31), + budget: 420.0, + essential_categories: vec![ + String::from("products"), + String::from("transport"), + String::from("utilities"), + ], + days: vec![ + Day { + date: NaiveDate::from_ymd(2020, 10, 1), + expenses: vec![ + Expense { + name: String::from("Potato masher"), + price: 3.81, + qty: 1, + shared: 1, + recurring: false, + category: Some(String::from("supplies")), + }, + Expense { + name: String::from("Bacon"), + price: 3.33, + qty: 1, + shared: 2, + recurring: false, + category: Some(String::from("products")), + }, + Expense { + name: String::from("Yoghurt"), + price: 1.24, + qty: 2, + shared: 1, + recurring: false, + category: Some(String::from("products")), + }, + Expense { + name: String::from("Onion"), + price: 0.15, + qty: 1, + shared: 1, + recurring: false, + category: Some(String::from("products")), + }, + Expense { + name: String::from("Chicken"), + price: 2.28, + qty: 1, + shared: 2, + recurring: false, + category: Some(String::from("products")), + }, + ], + }, + Day { + date: NaiveDate::from_ymd(2020, 10, 2), + expenses: vec![ + Expense { + name: String::from("VPS"), + price: 5.0, + qty: 1, + shared: 1, + recurring: true, + category: Some(String::from("utilities")), + }, + Expense { + name: String::from("Transport card"), + price: 6.9, + qty: 1, + shared: 1, + recurring: false, + category: Some(String::from("transport")), + }, + ], + }, + ], + }; + + let actually_is = budget::parse_account("tests/test.toml")?; + + assert_eq!(actually_is, should_be); + + Ok(()) +} + +#[test] +fn can_calculate() -> Result<(), ParseError> { + let mut should_be = Calculated { + all_day_average: 11.355, + essential_day_average: 9.45, + categories_day_average: HashMap::::new(), + essential_subtotal: 18.9, + categories_subtotal: HashMap::::new(), + total: 22.71, + balance: 397.29, + days_left: 34.9881109643329, + days_left_essential: 42.041269841269845, + }; + + should_be.categories_day_average.insert( + "supplies".to_string(), + 1.905, + ); + should_be.categories_day_average.insert( + "products".to_string(), + 3.5, + ); + should_be.categories_day_average.insert( + "transport".to_string(), + 3.45, + ); + should_be.categories_day_average.insert( + "utilities".to_string(), + 2.5, + ); + + should_be.categories_subtotal.insert( + "supplies".to_string(), + 3.81, + ); + should_be.categories_subtotal.insert( + "products".to_string(), + 7.0, + ); + should_be.categories_subtotal.insert( + "transport".to_string(), + 6.9, + ); + should_be.categories_subtotal.insert( + "utilities".to_string(), + 5.0, + ); + + let account = budget::parse_account("tests/test.toml")?; + let actually_is = budget::calculate(&account); + + assert_eq!(actually_is, should_be); + + Ok(()) +} diff --git a/budget/tests/test.toml b/budget/tests/test.toml new file mode 100644 index 0000000..7dd5774 --- /dev/null +++ b/budget/tests/test.toml @@ -0,0 +1,53 @@ +start_date = 2020-10-01 +end_date = 2020-10-31 +budget = 420.0 +essential_categories = [ + "products", + "transport", + "utilities", +] + +[[days]] +date = 2020-10-01 + + [[days.expenses]] + name = "Potato masher" + price = 3.81 + category = "supplies" + + [[days.expenses]] + name = "Bacon" + price = 3.33 + category = "products" + shared = 2 + + [[days.expenses]] + name = "Yoghurt" + price = 1.24 + category = "products" + qty = 2 + + [[days.expenses]] + name = "Onion" + price = 0.15 + category = "products" + + [[days.expenses]] + name = "Chicken" + price = 2.28 + category = "products" + shared = 2 + +[[days]] +date = 2020-10-02 + + [[days.expenses]] + name = "VPS" + price = 5.0 + category = "utilities" + recurring = true + + [[days.expenses]] + name = "Transport card" + price = 6.9 + category = "transport" -- cgit v1.2.3