aboutsummaryrefslogtreecommitdiff
path: root/budget
diff options
context:
space:
mode:
Diffstat (limited to 'budget')
-rw-r--r--budget/Cargo.toml12
-rw-r--r--budget/src/lib.rs162
-rw-r--r--budget/tests/budget.rs149
-rw-r--r--budget/tests/test.toml53
4 files changed, 376 insertions, 0 deletions
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 <contact@yaroslavps.com>"]
+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<String>,
+ pub days: Vec<Day>,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct Day {
+ #[serde(deserialize_with = "deserialize_date")]
+ pub date: NaiveDate,
+ pub expenses: Vec<Expense>,
+}
+
+#[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<String>,
+}
+
+#[derive(PartialEq, Debug)]
+pub struct Calculated {
+ pub all_day_average: f64,
+ pub essential_day_average: f64,
+ pub categories_day_average: HashMap<String, f64>,
+ pub essential_subtotal: f64,
+ pub categories_subtotal: HashMap<String, f64>,
+ 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<NaiveDate, D::Error>
+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<Account, ParseError> {
+ let contents = match fs::read_to_string(path) {
+ Ok(data) => data,
+ Err(error) => {
+ return Err(ParseError::IOError(error.kind()));
+ },
+ };
+
+ match toml::from_str::<Account>(&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::<String, f64>::new(),
+ essential_subtotal: 0.0,
+ categories_subtotal: HashMap::<String, f64>::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<Calculated, ParseError> {
+ 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::<String, f64>::new(),
+ essential_subtotal: 18.9,
+ categories_subtotal: HashMap::<String, f64>::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"