From 408b0ac993496b108ec1e479151d549e9535051a Mon Sep 17 00:00:00 2001
From: Yaroslav <contact@yaroslavps.com>
Date: Mon, 5 Oct 2020 21:20:59 +0300
Subject: initial commit

---
 budget/src/lib.rs | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 162 insertions(+)
 create mode 100644 budget/src/lib.rs

(limited to 'budget/src')

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))
+}
-- 
cgit v1.2.3