diff options
| author | Yaroslav <contact@yaroslavps.com> | 2020-10-05 21:20:59 +0300 | 
|---|---|---|
| committer | Yaroslav <contact@yaroslavps.com> | 2020-10-05 21:20:59 +0300 | 
| commit | 408b0ac993496b108ec1e479151d549e9535051a (patch) | |
| tree | 6f19aa190a7f5707cc037f3f9590a89187582254 /budget | |
| download | finbudg-408b0ac993496b108ec1e479151d549e9535051a.tar.gz finbudg-408b0ac993496b108ec1e479151d549e9535051a.zip | |
initial commitv0.1.0
Diffstat (limited to 'budget')
| -rw-r--r-- | budget/Cargo.toml | 12 | ||||
| -rw-r--r-- | budget/src/lib.rs | 162 | ||||
| -rw-r--r-- | budget/tests/budget.rs | 149 | ||||
| -rw-r--r-- | budget/tests/test.toml | 53 | 
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" | 
