forex_split/lib.rs
1//! # Forex Split
2//!
3//! This crate was made to split a receipt in one currency into categories for bookkeeping in another currency, in order to facilitate bookkeeping in [YNAB](https://www.ynab.com/) while travelling in other countries.
4//!
5//! ## Use Case
6//!
7//! Say you go out to a restaurant in Germany and pay with your Swedish card. Your receipt might be itemized as follows.
8//!
9//! ```bash
10//! EUR
11//! 1 x Bier 0,4l 6,90
12//! 1 x Currywurst 17,50
13//!
14//! Summe 24,40
15//! Trinkgeld 2,44
16//!
17//! Kreditkarte 26,84
18//! ```
19//!
20//! Your bank statement, on the other hand is in Swedish crowns.
21//!
22//! ```bash
23//! SEK
24//! Tysk restaurang -299,28
25//! ```
26//!
27//! You want to categorize the beer, the sausage, and the tip into alcohol, eating out, and tip categories respectively, in your Swedish crown YNAB budget. This is what this crate is for.
28//!
29//! ## Usage
30//!
31//! ### Calling the Program
32//!
33//! Using the numbers from our [use case](#use-case) above we can call forex-split with one or two positional arguments. The first being the number from our bank statement and the second being the total from the receipt:
34//!
35//! ```bash
36//! forex-split 299.28 26.84
37//! ```
38//!
39//! The program will ask for any missing arguments:
40//!
41//! ```bash
42//! forex-split 299.28
43//! Please enter foreign total: 26.84
44//! ```
45//!
46//! ```bash
47//! forex-split
48//! Please enter domestic total: 299.28
49//! Please enter foreign total: 26.84
50//! ```
51//!
52//! The terms domestic and foregin are taken to mean domestic to your home country (SEK in our example) and foreign as in the currency of the, to you, foreign country in which you are travelling (EUR in our example).
53//!
54//! ### Running the Program
55//!
56//! After entering the totals in both domestic and foreign currencies, you will be asked to assign sums in the foreign currency to categories until either the total equals or exceeds the total stated on the receipt or you assign the remainder of the total from the receipt to a category, called *Other*, by not explicitly typing a number in. Naming categories is optional, but if a category is named several entries can be made into the same category, giving the total value of the category at the end.
57//!
58//! > **NOTE!** Note that output is rounded to two decimals.
59//!
60//! #### Manually Assigning All Categories
61//!
62//! ```bash
63//! forex-split 299.28 26.84
64//! Foreign subtotal (category subtotal) (remaining: 26.84): Alcohol 6.9
65//! Foreign subtotal (category subtotal) (remaining: 19.94): Food 17.5
66//! Foreign subtotal (category subtotal) (remaining: 2.44): Tip 2.44
67//! Category Foreign subtotal Domestic subtotal
68//! Alcohol 6.90 76.94
69//! Food 17.50 195.13
70//! Tip 2.44 27.21
71//! ```
72//!
73//! #### Using the Other Category
74//!
75//! ```bash
76//! forex-split 299.28 26.84
77//! Foreign subtotal (category subtotal) (remaining: 26.84): Alcohol 6.9
78//! Foreign subtotal (category subtotal) (remaining: 19.94): Food 17.5
79//! Foreign subtotal (category subtotal) (remaining: 2.44):
80//! Category Foreign subtotal Domestic subtotal
81//! Alcohol 6.90 76.94
82//! Food 17.50 195.13
83//! Other 2.44 27.21
84//! ```
85//!
86//! #### Using Unnamed Categories
87//!
88//! ```bash
89//! forex-split 299.28 26.84
90//! Foreign subtotal (category subtotal) (remaining: 26.84): 6.9
91//! Foreign subtotal (category subtotal) (remaining: 19.94): 17.5
92//! Foreign subtotal (category subtotal) (remaining: 2.44): 2.44
93//! Category Foreign subtotal Domestic subtotal
94//! Unnamed Category 1 6.90 76.94
95//! Unnamed Category 2 17.50 195.13
96//! Unnamed Category 3 2.44 27.21
97//! ```
98//!
99//! #### Using Unnamed Categories and the Other Category
100//!
101//! ```bash
102//! forex-split 299.28 26.84
103//! Foreign subtotal (category subtotal) (remaining: 26.84): 6.9
104//! Foreign subtotal (category subtotal) (remaining: 19.94): 17.5
105//! Foreign subtotal (category subtotal) (remaining: 2.44):
106//! Unnamed Category 1 6.90 76.94
107//! Unnamed Category 2 17.50 195.13
108//! Other 2.44 27.21
109//! ```
110//!
111//! #### Adding Several Items to the Same Category
112//!
113//! When you have several items on a receipt belonging to the same category is when bookkeeping while traveling can become a bit complicated. This is where forex-split can really help. Say you had two smaller dishes instead of a larger one. You can just enter them as belonging to the same category and forex-split will add them up for you.
114//!
115//! ```bash
116//! forex-split 299.28 26.84
117//! Foreign subtotal (category subtotal) (remaining: 26.84): Alcohol 6.9
118//! Foreign subtotal (category subtotal) (remaining: 19.94): Food 9.94
119//! Foreign subtotal (category subtotal) (remaining: 10.00): Food 7.56
120//! Foreign subtotal (category subtotal) (remaining: 2.44): Tip 2.44
121//! Category Foreign subtotal Domestic subtotal
122//! Alcohol 6.90 76.94
123//! Food 17.50 195.13
124//! Tip 2.44 27.21
125//! ```
126use clap::Parser;
127use std::io;
128use std::io::Write;
129use std::process::exit;
130use std::collections::HashMap;
131use std::fmt::Debug;
132
133/// Flush to stdout. If it is not possible, print an error message and exit the program.
134macro_rules! flush {
135 () => {
136 match io::stdout().flush() {
137 Ok(_) => (),
138 Err(_) => {
139 eprintln!("Unable to write terminal output.");
140 exit(1);
141 },
142 }
143 };
144}
145
146/// Get input from terminal and store it in a variable.
147macro_rules! input {
148 ($input:ident) => {
149 let mut $input = String::new();
150 match io::stdin().read_line(&mut $input) {
151 Ok(_) => (),
152 Err(_) => {
153 eprintln!("Unable to read terminal input.");
154 exit(1);
155 },
156 }
157 };
158}
159
160/// Split a receipt in one currency into categories for bookkeeping in another currency.
161#[derive(Parser)]
162struct Args {
163 /// Total in the currency from your bank statement, i.e. probably the currency domestic to your home country.
164 domestic_total: Option<f64>,
165 /// Total in the currency on the receipt from the purchase, i.e. the, to you, foreign currency from the country you are traveling in.
166 foreign_total: Option<f64>,
167}
168
169impl Args {
170 /// Parse command line arguments.
171 pub fn new() -> Self {
172 Self::parse()
173 }
174
175 /// Get a total from command line arguments, if available, otherwise ask the user to enter a total.
176 fn get_total(desired_total: Option<f64>, prompt: &str) -> f64 {
177 match desired_total {
178 Some(total) => total,
179 None => {
180 print!("{}", prompt);
181 flush!();
182 input_float()
183 },
184 }
185 }
186
187 /// Return the domestic total from the command line arguments, if one exists. Otherwise ask the user to provide a domestic total.
188 fn domestic_total(&self) -> f64 {
189 Self::get_total(self.domestic_total, "Please enter domestic total: ")
190 }
191
192 /// Return the foreign total from the command line arguments, if one exists. Otherwise ask the user to provide a foreign total.
193 fn foreign_total(&self) -> f64 {
194 Self::get_total(self.foreign_total, "Please enter foreign total: ")
195 }
196}
197
198/// Get terminal input and parse it to an f64. If no string that can be parsed to an f64 is provided, ask the user to provide a new string, until one that can be parsed to and f64 is provided. Prints an error message and exits the program if unable to write get input from the terminal.
199fn input_float() -> f64 {
200 let parsed_input = loop {
201 input!(input);
202 match input
203 .trim()
204 .parse::<f64>() {
205 Ok(parsed_input) => break parsed_input,
206 Err(_) => {
207 print!("Please try again. Enter a valid number: ");
208 flush!();
209 continue;
210 },
211 }
212 };
213 parsed_input
214}
215
216/// Store the conversion factor between the domestic and the foreign currency, as well as the category subtotals.
217#[derive(Debug)]
218struct CategoryList {
219 /// The conversion factor between the domestic and the foreign currency.
220 conversion_factor: f64,
221 /// The part of the foreign currency amount that has not yet been assigned to a category.
222 remaining_foreign_total: f64,
223 /// A list of categories and corresponding subtotals.
224 categories: HashMap<String, f64>,
225}
226
227impl CategoryList {
228 /// Create a new CategoryList, by taking an Args as the only argument.
229 pub fn new(args: Args) -> Self {
230 let domestic_total = args.domestic_total();
231 let foreign_total = args.foreign_total();
232 Self {
233 conversion_factor: domestic_total / &foreign_total,
234 remaining_foreign_total: foreign_total,
235 categories: HashMap::new(),
236 }
237 }
238
239 /// Add a value to a category or, if the category does not yet exist, create the category and assign the value.
240 fn assign_to_category(&mut self, category_name: String, value_to_assign: f64) -> () {
241 match &self.categories.get(&category_name) {
242 &Some(&value) => &self.categories.insert(category_name, &value + value_to_assign),
243 None => &self.categories.insert(category_name, value_to_assign),
244 };
245 }
246
247 /// Get user input of categories.
248 pub fn input_loop(&mut self) -> () {
249 type CounterType = u64;
250 let mut category_counter: CounterType = 0;
251
252 loop {
253 print!("Foreign subtotal (category subtotal) (remaining: {:.2}): ", &self.remaining_foreign_total);
254 flush!();
255 input!(input);
256
257 // Check whether input is parsable, otherwise print an error message and try again.
258 match input
259 .trim()
260 .parse::<String>() {
261 Ok(parsed_input) => {
262 let mut split_input = parsed_input
263 .split_whitespace()
264 .collect::<Vec<_>>();
265
266 // Check whether input was provided, otherwise assign remaining money to a category named Other and exit loop.
267 let subtotal = match split_input.last() {
268 Some(subtotal) => {
269
270 // Check whether the last element on the line is an f64, otherwise print an error message and try again.
271 match subtotal.parse::<f64>() {
272 Ok(subtotal) => subtotal,
273 Err(_) => {
274 eprintln!("Please enter the subtotal as the last element on the line, preceeded by a space. Try again.");
275 continue;
276 }
277 }
278 },
279 None => {
280 self.assign_to_category("Other".to_string(), self.remaining_foreign_total);
281 break;
282 }
283 };
284
285 // Check whether a high enough amount remains to subtract the subtotal.
286 if self.remaining_foreign_total - subtotal < 0f64 {
287 eprintln!("The entered subtotal exceeds the remaining total. Try again or just press enter to assign the entire remaining total to a category named Other.");
288 continue;
289 }
290
291 // Prepare a category name.
292 split_input.remove(split_input.len() - 1);
293 let mut category_name = split_input.join(" ");
294
295 // If no name for a category was provided, make a new unnamed, numbered category.
296 if category_name == "" {
297 // Check so that integer overflow does not occur.
298 category_counter = match category_counter.checked_add(1) {
299 Some(new_counter_value) => new_counter_value,
300 None => {
301 println!("A maximum of {} unnamed categories are supported. Please name category explicitly. Try again.", CounterType::MAX);
302 continue;
303 }
304 };
305
306 // Make new unnamed category name.
307 category_name = format!("Unnamed category {}", category_counter);
308 }
309
310 // Assign subtotal to category.
311 self.assign_to_category(category_name, subtotal);
312
313 // Subtract the subtotal from the remaining amout.
314 self.remaining_foreign_total -= subtotal;
315
316 // Exit loop if all money has been assigned.
317 if self.remaining_foreign_total == 0f64 {
318 break;
319 }
320 },
321 Err(_) => {
322 eprintln!("Unable to parse terminal input. Try again.");
323 continue;
324 },
325 }
326 }
327 }
328
329 /// Returns a sorted list of category names, with named categories first, unnamed categories in numerical order second, and the Other category last.
330 fn ordered_category_list(&self) -> Vec<&String> {
331 let mut named_categories = Vec::new();
332 let mut unnamed_categories = Vec::new();
333 let mut other_category = Vec::new();
334
335 for category in self.categories.keys().collect::<Vec<_>>() {
336 if *category == "Other" {
337 other_category.push(category);
338 continue;
339 } else if category.starts_with("Unnamed category") {
340 unnamed_categories.push(category);
341 continue;
342 } else {
343 named_categories.push(category);
344 continue;
345 }
346 }
347
348 named_categories.sort();
349 unnamed_categories.sort();
350
351 let mut ordered_categories = named_categories;
352 ordered_categories.append(&mut unnamed_categories);
353 ordered_categories.append(&mut other_category);
354
355 ordered_categories
356 }
357
358 /// Prints a table of categories with foreign amounts converted to the domestic currency.
359 pub fn print_split_table(&self) -> () {
360 println!("{:<20}{:>20}{:>20}", "Category", "Foreign subtotal", "Domestic subtotal");
361 for category in self.ordered_category_list() {
362 let foreign_amount = match self.categories.get(category) {
363 Some(foreign_amount) => foreign_amount,
364 None => {
365 eprint!("Tried to read a category that does not exist from memory. Exiting.");
366 exit(1);
367 }
368 };
369 println!("{:<20}{:>20.2}{:>20.2}", category, foreign_amount, foreign_amount * self.conversion_factor);
370 }
371 }
372}
373
374/// # Run main program logic
375///
376/// This function is run in `main.rs` as follows.
377///
378/// ```no_run
379/// use forex_split::run;
380///
381/// fn main() {
382/// run();
383/// }
384/// ```
385pub fn run() -> () {
386 let args = Args::new();
387 let mut category_list = CategoryList::new(args);
388 category_list.input_loop();
389 category_list.print_split_table();
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 use std::collections::HashMap;
397
398 #[test]
399 fn category_list_new_works() -> Result<(), String> {
400 let test_category_list = CategoryList::new(Args { domestic_total: Some(10f64), foreign_total: Some(20f64) });
401 let _empty_hash_map: HashMap<String, f64> = HashMap::new();
402
403 assert_eq!(test_category_list.conversion_factor, 0.5f64);
404 assert_eq!(test_category_list.remaining_foreign_total, 20f64);
405 assert_eq!(test_category_list.categories, _empty_hash_map);
406
407 Ok(())
408 }
409
410 #[test]
411 fn args_get_total_returns_provided_value() -> Result<(), String> {
412 assert_eq!(Args::get_total(Some(12.34f64), "unused prompt"), 12.34f64);
413
414 Ok(())
415 }
416
417 #[test]
418 fn args_domestic_total_returns_provided_value() -> Result<(), String> {
419 let test_args = Args { domestic_total: Some(10f64), foreign_total: Some(20f64) };
420
421 assert_eq!(test_args.domestic_total(), 10f64);
422
423 Ok(())
424 }
425
426 #[test]
427 fn args_foreign_total_returns_provided_value() -> Result<(), String> {
428 let test_args = Args { domestic_total: Some(10f64), foreign_total: Some(20f64) };
429
430 assert_eq!(test_args.foreign_total(), 20f64);
431
432 Ok(())
433 }
434
435 #[test]
436 fn inital_category_list_assign_to_category_works() -> Result<(), String> {
437 let mut test_category_list = CategoryList::new(Args { domestic_total: Some(10f64), foreign_total: Some(20f64) });
438 let mut _test_hash_map: HashMap<String, f64> = HashMap::new();
439 _test_hash_map.insert("Foo".to_string(), 5f64);
440 test_category_list.assign_to_category("Foo".to_string(), 5f64);
441
442 assert_eq!(test_category_list.conversion_factor, 0.5f64);
443 assert_eq!(test_category_list.remaining_foreign_total, 20f64);
444 assert_eq!(test_category_list.categories, _test_hash_map);
445
446 Ok(())
447 }
448
449 #[test]
450 fn second_category_list_assign_to_category_works() -> Result<(), String> {
451 let mut test_category_list = CategoryList::new(Args { domestic_total: Some(10f64), foreign_total: Some(20f64) });
452 let mut _test_hash_map: HashMap<String, f64> = HashMap::new();
453 _test_hash_map.insert("Foo".to_string(), 10f64);
454 test_category_list.assign_to_category("Foo".to_string(), 5f64);
455 test_category_list.assign_to_category("Foo".to_string(), 5f64);
456
457 assert_eq!(test_category_list.conversion_factor, 0.5f64);
458 assert_eq!(test_category_list.remaining_foreign_total, 20f64);
459 assert_eq!(test_category_list.categories, _test_hash_map);
460
461 Ok(())
462 }
463
464 #[test]
465 fn ordered_categories_sorts_categories_correctly() -> Result<(), String> {
466 let mut test_category_list = CategoryList::new(Args { domestic_total: Some(10f64), foreign_total: Some(20f64) });
467 test_category_list.assign_to_category("Foo".to_string(), 1f64);
468 test_category_list.assign_to_category("Bar".to_string(), 1f64);
469 test_category_list.assign_to_category("Unnamed category 2".to_string(), 1f64);
470 test_category_list.assign_to_category("Unnamed category 1".to_string(), 1f64);
471 test_category_list.assign_to_category("Unnamed category 8".to_string(), 1f64);
472 test_category_list.assign_to_category("Unnamed category 5".to_string(), 1f64);
473 test_category_list.assign_to_category("Other".to_string(), 1f64);
474 let correct_order = vec!["Bar", "Foo", "Unnamed category 1", "Unnamed category 2", "Unnamed category 5", "Unnamed category 8", "Other"];
475
476 assert_eq!(test_category_list.ordered_category_list(), correct_order);
477
478 Ok(())
479 }
480}