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,); | 
