diff options
-rw-r--r-- | Cargo.lock | 2 | ||||
-rw-r--r-- | Cargo.toml | 4 | ||||
-rw-r--r-- | budget/src/lib.rs | 81 | ||||
-rw-r--r-- | budget/tests/budget.rs | 121 | ||||
-rw-r--r-- | budget/tests/test.toml | 19 | ||||
-rw-r--r-- | src/main.rs | 50 |
6 files changed, 179 insertions, 98 deletions
@@ -82,7 +82,7 @@ dependencies = [ [[package]] name = "finbudg" -version = "0.2.0" +version = "0.3.0" dependencies = [ "budget", "chrono", @@ -1,11 +1,11 @@ [package] name = "finbudg" -version = "0.2.0" +version = "0.3.0" edition = "2018" description = "Quick cli tool to calculate your expenses and balance for a set period of time." license = "MIT" readme = "README.md" -authors = ["Yaroslav de la Peña Smirnov <contact@yaroslavps.com>"] +authors = ["Yaroslav de la Peña Smirnov <yps@yaroslavps.com>"] homepage = "https://www.yaroslavps.com/" repository = "https://github.com/Yaroslav-95/finbudg" diff --git a/budget/src/lib.rs b/budget/src/lib.rs index 5013a9f..e848035 100644 --- a/budget/src/lib.rs +++ b/budget/src/lib.rs @@ -30,12 +30,14 @@ pub struct Day { 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)] + /// Whom this expense is shared with (if anybody). + pub shared: Vec<String>, + #[serde(default)] + /// Whether this was something we paid for somebody else, and thus is owed + /// to us. If true, then shared is the list of person(s) that owe us this + /// expense, and should therefore contain at least one name. + pub owed: bool, #[serde(default)] pub category: Option<String>, } @@ -49,7 +51,8 @@ pub struct Calculated { pub categories_subtotal: HashMap<String, f64>, pub total: f64, pub balance: f64, - pub total_owed: HashMap<u32, f64>, + pub owed: HashMap<String, f64>, + pub total_owed: f64, pub days_left: f64, pub days_left_essential: f64, pub last_day: NaiveDate, @@ -61,14 +64,6 @@ pub enum ParseError { DeserializerError(DeserializerError), } -fn shared_qty_default() -> u32 { - 1 -} - -fn recurring_default() -> bool { - false -} - // Parse the dates from toml's Datetime to Chrono's NaiveDate fn deserialize_date<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error> where @@ -97,7 +92,7 @@ pub fn parse_account(path: &str) -> Result<Account, ParseError> { } } -pub fn calculate(account: &Account) -> Option<Calculated> { +pub fn calculate(account: &Account, consider_owed: bool) -> Option<Calculated> { if account.days.is_empty() { return None; } @@ -110,7 +105,8 @@ pub fn calculate(account: &Account) -> Option<Calculated> { categories_subtotal: HashMap::<String, f64>::new(), total: 0.0, balance: 0.0, - total_owed: HashMap::<u32, f64>::new(), + owed: HashMap::<String, f64>::new(), + total_owed: 0.0, days_left: 0.0, days_left_essential: 0.0, last_day: account.days.last().unwrap().date, @@ -122,34 +118,51 @@ pub fn calculate(account: &Account) -> Option<Calculated> { } for expense in day.expenses.iter() { - calculated.total += expense.price; + let mut actual_expense: f64 = 0.0; + + if expense.shared.len() > 0 { + let owed_share = if expense.owed { + expense.price / expense.shared.len() as f64 + } else { + actual_expense = + expense.price / (expense.shared.len() as f64 + 1.0); + actual_expense + }; + + for person in expense.shared.iter() { + calculated.total_owed += owed_share; + + if let Some(owed_by_person) = + calculated.owed.get_mut(person) + { + *owed_by_person += owed_share; + } else { + calculated.owed.insert(person.clone(), owed_share); + } + } + } + + if expense.shared.len() == 0 || consider_owed { + actual_expense = expense.price; + } else if expense.owed { + continue; + } + + calculated.total += actual_expense; if let Some(category) = &expense.category { if let Some(category_subtotal) = calculated.categories_subtotal.get_mut(category) { - *category_subtotal += expense.price; + *category_subtotal += actual_expense; } else { calculated .categories_subtotal - .insert(category.to_string(), expense.price); + .insert(category.to_string(), actual_expense); } if account.essential_categories.contains(category) { - calculated.essential_subtotal += expense.price; - } - - if expense.shared > 1 { - let owed = expense.price * (expense.shared as f64 - 1.0) - / expense.shared as f64; - - if let Some(total_owed_by) = - calculated.total_owed.get_mut(&expense.shared) - { - *total_owed_by += owed; - } else { - calculated.total_owed.insert(expense.shared, owed); - } + calculated.essential_subtotal += actual_expense; } } } diff --git a/budget/tests/budget.rs b/budget/tests/budget.rs index 4ed549c..836a715 100644 --- a/budget/tests/budget.rs +++ b/budget/tests/budget.rs @@ -11,7 +11,7 @@ fn can_parse_account() -> Result<(), ParseError> { end_date: NaiveDate::from_ymd(2020, 10, 31), budget: 420.0, essential_categories: vec![ - String::from("products"), + String::from("produce"), String::from("transport"), String::from("utilities"), ], @@ -22,42 +22,40 @@ fn can_parse_account() -> Result<(), ParseError> { Expense { name: String::from("Potato masher"), price: 3.81, - qty: 1, - shared: 1, - recurring: false, + shared: vec![], + owed: false, category: Some(String::from("supplies")), }, Expense { name: String::from("Bacon"), price: 3.33, - qty: 1, - shared: 3, - recurring: false, - category: Some(String::from("products")), + shared: vec![ + String::from("Fox"), + String::from("Falco"), + ], + owed: false, + category: Some(String::from("produce")), }, Expense { name: String::from("Yoghurt"), price: 1.24, - qty: 2, - shared: 2, - recurring: false, - category: Some(String::from("products")), + shared: vec![String::from("Falco")], + owed: true, + category: Some(String::from("produce")), }, Expense { name: String::from("Onion"), price: 0.15, - qty: 1, - shared: 1, - recurring: false, - category: Some(String::from("products")), + shared: vec![], + owed: false, + category: Some(String::from("produce")), }, Expense { name: String::from("Chicken"), price: 2.28, - qty: 1, - shared: 2, - recurring: false, - category: Some(String::from("products")), + shared: vec![String::from("Fox")], + owed: false, + category: Some(String::from("produce")), }, ], }, @@ -71,17 +69,15 @@ fn can_parse_account() -> Result<(), ParseError> { Expense { name: String::from("VPS"), price: 5.0, - qty: 1, - shared: 1, - recurring: true, + shared: vec![], + owed: false, category: Some(String::from("utilities")), }, Expense { name: String::from("Transport card"), price: 6.9, - qty: 1, - shared: 1, - recurring: false, + shared: vec![], + owed: false, category: Some(String::from("transport")), }, ], @@ -99,16 +95,17 @@ fn can_parse_account() -> Result<(), ParseError> { #[test] fn can_calculate() -> Result<(), ParseError> { let mut should_be = Calculated { - all_day_average: 5.6775, - essential_day_average: 4.725, + all_day_average: 4.5275, + essential_day_average: 3.575, categories_day_average: HashMap::<String, f64>::new(), - essential_subtotal: 18.9, + essential_subtotal: 14.3, categories_subtotal: HashMap::<String, f64>::new(), - total: 22.71, - balance: 397.29, - total_owed: HashMap::<u32, f64>::new(), - days_left: 69.9762219286658, - days_left_essential: 84.08253968253969, + total: 18.11, + balance: 401.89, + owed: HashMap::<String, f64>::new(), + total_owed: 4.6, + days_left: 88.76642738818333, + days_left_essential: 112.4167832167832, last_day: NaiveDate::from_ymd(2020, 10, 04), }; @@ -117,7 +114,7 @@ fn can_calculate() -> Result<(), ParseError> { .insert("supplies".to_string(), 0.9525); should_be .categories_day_average - .insert("products".to_string(), 1.75); + .insert("produce".to_string(), 0.6); should_be .categories_day_average .insert("transport".to_string(), 1.725); @@ -130,7 +127,7 @@ fn can_calculate() -> Result<(), ParseError> { .insert("supplies".to_string(), 3.81); should_be .categories_subtotal - .insert("products".to_string(), 7.0); + .insert("produce".to_string(), 2.4); should_be .categories_subtotal .insert("transport".to_string(), 6.9); @@ -138,13 +135,59 @@ fn can_calculate() -> Result<(), ParseError> { .categories_subtotal .insert("utilities".to_string(), 5.0); - should_be.total_owed.insert(2, 1.7599999999999998); - should_be.total_owed.insert(3, 2.22); + should_be.owed.insert(String::from("Fox"), 2.25); + should_be.owed.insert(String::from("Falco"), 2.35); + + let mut should_be_with_owed = Calculated { + all_day_average: 5.6775, + essential_day_average: 4.725, + categories_day_average: HashMap::<String, f64>::new(), + essential_subtotal: 18.9, + categories_subtotal: HashMap::<String, f64>::new(), + total: 22.71, + balance: 397.29, + owed: HashMap::<String, f64>::new(), + total_owed: 4.6, + days_left: 69.9762219286658, + days_left_essential: 84.08253968253969, + last_day: NaiveDate::from_ymd(2020, 10, 04), + }; + + should_be_with_owed + .categories_day_average + .insert("supplies".to_string(), 0.9525); + should_be_with_owed + .categories_day_average + .insert("produce".to_string(), 1.75); + should_be_with_owed + .categories_day_average + .insert("transport".to_string(), 1.725); + should_be_with_owed + .categories_day_average + .insert("utilities".to_string(), 1.25); + + should_be_with_owed + .categories_subtotal + .insert("supplies".to_string(), 3.81); + should_be_with_owed + .categories_subtotal + .insert("produce".to_string(), 7.0); + should_be_with_owed + .categories_subtotal + .insert("transport".to_string(), 6.9); + should_be_with_owed + .categories_subtotal + .insert("utilities".to_string(), 5.0); + + should_be_with_owed.owed.insert(String::from("Fox"), 2.25); + should_be_with_owed.owed.insert(String::from("Falco"), 2.35); let account = budget::parse_account("tests/test.toml")?; - let actually_is = budget::calculate(&account).unwrap(); + let actually_is = budget::calculate(&account, false).unwrap(); + let actually_is_with_owed = budget::calculate(&account, true).unwrap(); assert_eq!(actually_is, should_be); + assert_eq!(actually_is_with_owed, should_be_with_owed); Ok(()) } diff --git a/budget/tests/test.toml b/budget/tests/test.toml index ca2c1e5..103094e 100644 --- a/budget/tests/test.toml +++ b/budget/tests/test.toml @@ -2,7 +2,7 @@ start_date = 2020-10-01 end_date = 2020-10-31 budget = 420.0 essential_categories = [ - "products", + "produce", "transport", "utilities", ] @@ -18,26 +18,26 @@ date = 2020-10-01 [[days.expenses]] name = "Bacon" price = 3.33 - category = "products" - shared = 3 + category = "produce" + shared = ["Fox", "Falco"] [[days.expenses]] name = "Yoghurt" price = 1.24 - category = "products" - qty = 2 - shared = 2 + category = "produce" + owed = true + shared = ["Falco"] [[days.expenses]] name = "Onion" price = 0.15 - category = "products" + category = "produce" [[days.expenses]] name = "Chicken" price = 2.28 - category = "products" - shared = 2 + category = "produce" + shared = ["Fox"] [[days]] date = 2020-10-04 @@ -49,7 +49,6 @@ date = 2020-10-02 name = "VPS" price = 5.0 category = "utilities" - recurring = true [[days.expenses]] name = "Transport card" diff --git a/src/main.rs b/src/main.rs index 1dd437b..dec53ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ fn main() { let no_color = matches.occurrences_of("plain") > 0; let force_color = matches.occurrences_of("force-color") > 0; + let consider_owed = matches.occurrences_of("consider-owed") > 0; let input = matches.value_of("INPUT").unwrap(); let account = match budget::parse_account(input) { @@ -28,7 +29,7 @@ fn main() { ::std::process::exit(1); } }; - let maybe_calculated = budget::calculate(&account); + let maybe_calculated = budget::calculate(&account, consider_owed); if no_color && !force_color { colored::control::set_override(false); @@ -36,7 +37,7 @@ fn main() { colored::control::set_override(true); } - output(account, maybe_calculated); + output(account, maybe_calculated, consider_owed); } fn get_cli_matches() -> ArgMatches<'static> { @@ -45,6 +46,16 @@ fn get_cli_matches() -> ArgMatches<'static> { .author(crate_authors!()) .about(crate_description!()) .arg( + Arg::with_name("consider-owed") + .short("w") + .long("consider-owed") + .help( + "Take into account what's owed when calculating the total \ + and subtotals." + ) + .takes_value(false) + ) + .arg( Arg::with_name("plain") .short("p") .long("plain") @@ -60,21 +71,25 @@ fn get_cli_matches() -> ArgMatches<'static> { .help( "Forces colorized output even when piping. Takes \ precedence over --plain flag and NO_COLOR environment \ - variable", + variable.", ) .takes_value(false), ) .arg( Arg::with_name("INPUT") .help("Expenses file to calculate from. For more information \ - on the format of this file see 'man 5 finbudg'") + on the format of this file see 'man 5 finbudg'.") .required(true) .index(1), ) .get_matches() } -fn output(account: Account, maybe_calculated: Option<Calculated>) { +fn output( + account: Account, + maybe_calculated: Option<Calculated>, + consider_owed: bool, +) { println!( "{}", format!( @@ -166,20 +181,31 @@ fn output(account: Account, maybe_calculated: Option<Calculated>) { println!(); - for (n, owed) in calculated.total_owed.iter() { + for (person, owed) in calculated.owed.iter() { println!( - "{} person(s) owe you in shared expenses: {:.2}", - n - 1, + "{} owes you in shared expenses: {:.2}", + person, owed, ); + } - if *n > 2 { - println!("Each owes you: {}", *owed / (*n as f64 - 1.0)); + if calculated.owed.len() > 0 { + println!("In total you're owed: {:.2}", calculated.total_owed); + if consider_owed { + println!( + "Supposing you've been repaid, you should be left with: {:.2}", + calculated.balance + calculated.total_owed, + ); + } else { + println!( + "Assuming you haven't been repaid, you're left with: {:.2}", + calculated.balance - calculated.total_owed, + ); } - - println!(); } + println!(); + println!("Days until balance runs out:"); let days_left_output = format!("{:.2}", calculated.days_left,); |