Skip to main content

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}