diff --git a/Cargo.lock b/Cargo.lock index 7a92216..84acfed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ [root] name = "teller_cli" -version = "0.0.4" +version = "0.0.5" dependencies = [ "chrono 0.2.17 (registry+https://github.com/rust-lang/crates.io-index)", "docopt 0.6.78 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index 1ea101a..857b14c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "teller_cli" -version = "0.0.4" +version = "0.0.5" authors = ["Seb Insua "] [[bin]] diff --git a/README.md b/README.md index d7c6293..e1edd83 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ This tool provides useful ways of interrogating your bank through your command line, and is not merely meant to be a one-to-one match with underlying APIs. -It uses [Teller](http://teller.io) behind-the-scenes to interact with your UK bank, so you will need to have an account there. +It uses [Teller](http://teller.io) behind-the-scenes to interact with your UK bank, so you will need to have an account there. Want an account? [@stevegraham can hook you up!](https://twitter.com/stevegraham) **:point_up_2: Heads up!** (1) This is my first [Rust](https://www.rust-lang.org/) project, (2) the interface is in flux while I try to make it human-like without ending up redundant, and (3) there are [no tests yet](https://github.com/sebinsua/teller-cli/issues/1)! This is soon to change with the release of [v1.0.0](https://github.com/sebinsua/teller-cli/issues?q=is%3Aopen+is%3Aissue+milestone%3Av1.0.0). @@ -82,12 +82,29 @@ then fi ``` +#### Show your current balance and last transaction in the OSX Menu Bar with [Bitbar](https://github.com/matryer/bitbar) + +`show-current-balance.1h.sh` +```sh +#!/bin/sh +export PATH='/usr/local/bin:/usr/bin/:$PATH'; + +CURRENT_BALANCE=`teller show balance current --hide-currency`; +LAST_TRANSACTION=`teller list transactions | tail -n 1 | pcregrep -o1 "[0-9]+[ ]+(.*)"`; + +echo "£$CURRENT_BALANCE"; +echo "---"; +echo "$LAST_TRANSACTION"; +``` + +![Current Balance in OSX Menu Bar](http://i.imgur.com/BzkazSB.png) + ## Installation ### From release ``` -> curl -L https://github.com/sebinsua/teller-cli/releases/download/v0.0.4/teller > /usr/local/bin/teller && chmod +x /usr/local/bin/teller +> curl -L https://github.com/sebinsua/teller-cli/releases/download/v0.0.5/teller > /usr/local/bin/teller && chmod +x /usr/local/bin/teller ``` ### From source diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 0000000..5732c3e --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,184 @@ +use cli::arg_types::Timeframe; + +use hyper::{Client, Url}; +use hyper::header::{Authorization, Bearer}; +use rustc_serialize::json; +use chrono::{Date, DateTime, UTC}; +use chrono::duration::Duration; + +use std::io::prelude::*; // Required for read_to_string use later. + +use super::error::TellerClientError; + +pub type ApiServiceResult = Result; + +#[derive(Debug, RustcDecodable)] +struct AccountResponse { + data: Account, +} + +#[derive(Debug, RustcDecodable)] +struct AccountsResponse { + data: Vec, +} + +#[derive(Debug, RustcDecodable)] +struct TransactionsResponse { + data: Vec, +} + +#[derive(Debug, RustcDecodable)] +pub struct Account { + pub updated_at: String, + pub institution: String, + pub id: String, + pub currency: String, + pub balance: String, + pub account_number_last_4: String, +} + +#[derive(Debug, RustcDecodable)] +pub struct Transaction { + pub description: String, + pub date: String, + pub counterparty: String, + pub amount: String, +} + +pub fn parse_utc_date_from_transaction(t: &Transaction) -> Date { + let full_date = &(t.date.to_owned() + "T00:00:00-00:00"); + let past_transaction_date_without_tz = DateTime::parse_from_rfc3339(full_date).unwrap().date(); + let past_transaction_date = past_transaction_date_without_tz.with_timezone(&UTC); + past_transaction_date +} + +const TELLER_API_SERVER_URL: &'static str = "https://api.teller.io"; + +pub struct TellerClient<'a> { + auth_token: &'a str, +} + +impl<'a> TellerClient<'a> { + pub fn new(auth_token: &'a str) -> TellerClient { + TellerClient { + auth_token: auth_token, + } + } + + fn get_body(&self, url: &str) -> ApiServiceResult { + let client = Client::new(); + let mut res = try!(client.get(url) + .header(Authorization( + Bearer { token: self.auth_token.to_string() } + )) + .send()); + if res.status.is_client_error() { + return Err(TellerClientError::AuthenticationError); + } + + let mut body = String::new(); + try!(res.read_to_string(&mut body)); + + debug!("GET {} response: {}", url, body); + + Ok(body) + } + + pub fn get_accounts(&self) -> ApiServiceResult> { + let body = try!(self.get_body(&format!("{}/accounts", TELLER_API_SERVER_URL))); + let accounts_response: AccountsResponse = try!(json::decode(&body)); + + Ok(accounts_response.data) + } + + pub fn get_account(&self, account_id: &str) -> ApiServiceResult { + let body = try!(self.get_body(&format!("{}/accounts/{}", TELLER_API_SERVER_URL, account_id))); + let account_response: AccountResponse = try!(json::decode(&body)); + + Ok(account_response.data) + } + + pub fn raw_transactions(&self, + account_id: &str, + count: u32, + page: u32) + -> ApiServiceResult> { + let mut url = Url::parse(&format!("{}/accounts/{}/transactions", + TELLER_API_SERVER_URL, + account_id)).unwrap(); + + const COUNT: &'static str = "count"; + const PAGE: &'static str = "page"; + let query = vec![(COUNT, count.to_string()), (PAGE, page.to_string())]; + url.set_query_from_pairs(query.into_iter()); + + let body = try!(self.get_body(&url.serialize())); + let transactions_response: TransactionsResponse = try!(json::decode(&body)); + + Ok(transactions_response.data) + } + + pub fn get_transactions(&self, + account_id: &str, + timeframe: &Timeframe) + -> ApiServiceResult> { + let page_through_transactions = |from| -> ApiServiceResult> { + let mut all_transactions = vec![]; + + let mut fetching = true; + let mut page = 1; + let count = 250; + while fetching { + let mut transactions = try!(self.raw_transactions(&account_id, count, page)); + match transactions.last() { + None => { + // If there are no transactions left, do not fetch forever... + fetching = false + } + Some(past_transaction) => { + let past_transaction_date = parse_utc_date_from_transaction(&past_transaction); + if past_transaction_date < from { + fetching = false; + } + } + }; + + all_transactions.append(&mut transactions); + page = page + 1; + } + + all_transactions = all_transactions.into_iter() + .filter(|t| { + let transaction_date = + parse_utc_date_from_transaction(&t); + transaction_date > from + }) + .collect(); + + all_transactions.reverse(); + Ok(all_transactions) + }; + + match *timeframe { + Timeframe::ThreeMonths => { + let to = UTC::today(); + let from = to - Duration::days(91); // close enough... 😅 + + page_through_transactions(from) + } + Timeframe::SixMonths => { + let to = UTC::today(); + let from = to - Duration::days(183); + + page_through_transactions(from) + } + Timeframe::Year => { + let to = UTC::today(); + let from = to - Duration::days(365); + + page_through_transactions(from) + } + } + } + +} diff --git a/src/client/error.rs b/src/api/error.rs similarity index 100% rename from src/client/error.rs rename to src/api/error.rs diff --git a/src/api/inform/get_account_balance.rs b/src/api/inform/get_account_balance.rs new file mode 100644 index 0000000..9808bd5 --- /dev/null +++ b/src/api/inform/get_account_balance.rs @@ -0,0 +1,12 @@ +use super::{TellerClient, ApiServiceResult, Account, Money}; + +pub trait GetAccountBalance { + fn get_account_balance(&self, account_id: &str) -> ApiServiceResult; +} + +impl<'a> GetAccountBalance for TellerClient<'a> { + fn get_account_balance(&self, account_id: &str) -> ApiServiceResult { + let to_money = |a: Account| Money::new(a.balance, a.currency); + self.get_account(&account_id).map(to_money) + } +} diff --git a/src/api/inform/get_aggregates.rs b/src/api/inform/get_aggregates.rs new file mode 100644 index 0000000..38f18aa --- /dev/null +++ b/src/api/inform/get_aggregates.rs @@ -0,0 +1,195 @@ +use cli::arg_types::{Interval, Timeframe}; + +use std::str::FromStr; // Use of #from_str. + +use itertools::Itertools; + +use super::{TellerClient, ApiServiceResult, Transaction}; +use super::parse_utc_date_from_transaction; + +pub type Balances = HistoricalAmountsWithCurrency; +pub type Outgoings = HistoricalAmountsWithCurrency; +pub type Incomings = HistoricalAmountsWithCurrency; + +pub type IntervalAmount = (String, String); + +type DateStringToTransactions = (String, Vec); + +#[derive(Debug)] +pub struct HistoricalAmountsWithCurrency { + pub historical_amounts: Vec, + pub currency: String, +} + +impl HistoricalAmountsWithCurrency { + pub fn new>(historical_amounts: Vec, + currency: S) + -> HistoricalAmountsWithCurrency { + HistoricalAmountsWithCurrency { + historical_amounts: historical_amounts, + currency: currency.into(), + } + } +} + +pub trait GetBalances { + fn get_balances(&self, + account_id: &str, + interval: &Interval, + timeframe: &Timeframe) -> ApiServiceResult; +} + +pub trait GetOutgoings { + fn get_outgoings(&self, + account_id: &str, + interval: &Interval, + timeframe: &Timeframe) -> ApiServiceResult; +} + +pub trait GetIncomings { + fn get_incomings(&self, + account_id: &str, + interval: &Interval, + timeframe: &Timeframe) -> ApiServiceResult; +} + +fn to_grouped_transaction_aggregates(transactions: Vec, + interval: &Interval, + aggregate_txs: &Fn(DateStringToTransactions) -> (String, i64)) + -> Vec<(String, i64)> { + let mut month_year_grouped_transactions: Vec<(String, i64)> = transactions.into_iter().group_by(|t| { + let transaction_date = parse_utc_date_from_transaction(&t); + match *interval { + Interval::Monthly => { + let group_name = transaction_date.format("%m-%Y").to_string(); + group_name + } + } + }).map(aggregate_txs).collect(); + month_year_grouped_transactions.reverse(); + + month_year_grouped_transactions +} + +impl<'a> GetBalances for TellerClient<'a> { + fn get_balances(&self, + account_id: &str, + interval: &Interval, + timeframe: &Timeframe) + -> ApiServiceResult { + let sum_all = |myt: (String, Vec)| { + let to_cent_integer = |t: &Transaction| { + (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 + }; + + let group_name = myt.0; + let amount = myt.1.iter().map(to_cent_integer).fold(0i64, |sum, v| sum + v); + (group_name, amount) + }; + + let transactions = self.get_transactions(&account_id, &timeframe).unwrap_or(vec![]); + let month_year_total_transactions = to_grouped_transaction_aggregates(transactions, + &interval, + &sum_all); + + let account = try!(self.get_account(&account_id)); + let current_balance = (f64::from_str(&account.balance).unwrap() * 100f64).round() as i64; + let currency = account.currency; + + let mut historical_amounts: Vec = vec![]; + historical_amounts.push(("current".to_string(), + format!("{:.2}", current_balance as f64 / 100f64))); + + let mut last_balance = current_balance; + for mytt in month_year_total_transactions { + last_balance = last_balance - mytt.1; + historical_amounts.push((mytt.0.to_string(), + format!("{:.2}", last_balance as f64 / 100f64))); + } + historical_amounts.reverse(); + + Ok(HistoricalAmountsWithCurrency::new(historical_amounts, currency)) + } +} + +impl<'a> GetOutgoings for TellerClient<'a> { + fn get_outgoings(&self, + account_id: &str, + interval: &Interval, + timeframe: &Timeframe) + -> ApiServiceResult { + let sum_outgoings = |myt: (String, Vec)| { + let to_cent_integer = |t: &Transaction| { + (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 + }; + + let group_name = myt.0; + let amount = myt.1 + .iter() + .map(to_cent_integer) + .filter(|ci| *ci < 0) + .fold(0i64, |sum, v| sum + v); + (group_name, amount) + }; + + let transactions = self.get_transactions(&account_id, &timeframe).unwrap_or(vec![]); + let month_year_total_outgoing = to_grouped_transaction_aggregates(transactions, + &interval, + &sum_outgoings); + + let account = try!(self.get_account(&account_id)); + let currency = account.currency; + + let from_cent_integer_to_float_string = |amount: i64| format!("{:.2}", amount as f64 / 100f64); + + let mut historical_amounts: Vec = vec![]; + for mytt in month_year_total_outgoing { + historical_amounts.push((mytt.0.to_string(), + from_cent_integer_to_float_string(mytt.1.abs()))); + } + historical_amounts.reverse(); + + Ok(HistoricalAmountsWithCurrency::new(historical_amounts, currency)) + } +} + +impl<'a> GetIncomings for TellerClient<'a> { + fn get_incomings(&self, + account_id: &str, + interval: &Interval, + timeframe: &Timeframe) + -> ApiServiceResult { + let sum_incomings = |myt: (String, Vec)| { + let to_cent_integer = |t: &Transaction| { + (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 + }; + + let group_name = myt.0; + let amount = myt.1 + .iter() + .map(to_cent_integer) + .filter(|ci| *ci > 0) + .fold(0i64, |sum, v| sum + v); + (group_name, amount) + }; + + let transactions = self.get_transactions(&account_id, &timeframe).unwrap_or(vec![]); + let month_year_total_incoming = to_grouped_transaction_aggregates(transactions, + &interval, + &sum_incomings); + + let account = try!(self.get_account(&account_id)); + let currency = account.currency; + + let from_cent_integer_to_float_string = |amount: i64| format!("{:.2}", amount as f64 / 100f64); + + let mut historical_amounts: Vec = vec![]; + for mytt in month_year_total_incoming { + historical_amounts.push((mytt.0.to_string(), + from_cent_integer_to_float_string(mytt.1))); + } + historical_amounts.reverse(); + + Ok(HistoricalAmountsWithCurrency::new(historical_amounts, currency)) + } +} diff --git a/src/api/inform/get_counterparties.rs b/src/api/inform/get_counterparties.rs new file mode 100644 index 0000000..cfbe174 --- /dev/null +++ b/src/api/inform/get_counterparties.rs @@ -0,0 +1,102 @@ +use cli::arg_types::Timeframe; + +use std::str::FromStr; // Use of #from_str. + +use std::collections::HashMap; +use itertools::Itertools; + +use super::{TellerClient, ApiServiceResult, Transaction}; + +#[derive(Debug)] +pub struct CounterpartiesWithCurrrency { + pub counterparties: Vec<(String, String)>, + pub currency: String, +} + +impl CounterpartiesWithCurrrency { + pub fn new>(counterparties: Vec<(String, String)>, + currency: S) + -> CounterpartiesWithCurrrency { + CounterpartiesWithCurrrency { + counterparties: counterparties, + currency: currency.into(), + } + } +} + +pub trait GetCounterparties { + fn get_counterparties(&self, + account_id: &str, + timeframe: &Timeframe) -> ApiServiceResult; +} + +fn convert_to_counterparty_to_date_amount_list<'a>(transactions: &'a Vec) + -> HashMap> { + let grouped_counterparties = transactions.iter() + .fold(HashMap::new(), |mut acc: HashMap>, + t: &'a Transaction| { + let counterparty = t.counterparty.to_owned(); + if acc.contains_key(&counterparty) { + if let Some(txs) = acc.get_mut(&counterparty) { + txs.push(t); + } + } else { + let mut txs: Vec<&'a Transaction> = vec![]; + txs.push(t); + acc.insert(counterparty, txs); + } + + acc + }); + + grouped_counterparties.into_iter().fold(HashMap::new(), |mut acc, (counterparty, txs)| { + let date_amount_tuples = txs.into_iter() + .map(|tx| (tx.date.to_owned(), tx.amount.to_owned())) + .collect(); + acc.insert(counterparty.to_string(), date_amount_tuples); + acc + }) +} + +impl<'a> GetCounterparties for TellerClient<'a> { + fn get_counterparties(&self, + account_id: &str, + timeframe: &Timeframe) + -> ApiServiceResult { + let transactions = try!(self.get_transactions(&account_id, &timeframe)); + let account = try!(self.get_account(&account_id)); + + let to_cent_integer = |amount: &str| (f64::from_str(&amount).unwrap() * 100f64).round() as i64; + let from_cent_integer_to_float_string = |amount: &i64| { + format!("{:.2}", *amount as f64 / 100f64) + }; + + let outgoing_transactions: Vec = transactions.into_iter().filter(|tx| { + to_cent_integer(&tx.amount) < 0 + }).collect(); + + let counterparty_to_date_amount_list = + convert_to_counterparty_to_date_amount_list(&outgoing_transactions); + let sorted_counterparties = + counterparty_to_date_amount_list.into_iter() + .map(|(counterparty, date_amount_tuples)| { + let amount = + date_amount_tuples.iter().fold(0i64, |acc, dat| { + acc + to_cent_integer(&dat.1) + }); + (counterparty, amount.abs()) + }) + .sort_by(|&(_, amount_a), &(_, amount_b)| { + amount_a.cmp(&amount_b) + }); + let counterparties = sorted_counterparties.into_iter() + .map(|(counterparty, amount)| { + (counterparty, + from_cent_integer_to_float_string(&amount)) + }) + .collect(); + let currency = account.currency; + Ok(CounterpartiesWithCurrrency::new(counterparties, currency)) + } +} diff --git a/src/api/inform/get_incoming.rs b/src/api/inform/get_incoming.rs new file mode 100644 index 0000000..08e29ee --- /dev/null +++ b/src/api/inform/get_incoming.rs @@ -0,0 +1,40 @@ +use chrono::{UTC, Datelike}; + +use std::str::FromStr; // Use of #from_str. + +use super::{TellerClient, ApiServiceResult, Transaction, Money}; +use super::parse_utc_date_from_transaction; + +pub trait GetIncoming { + fn get_incoming(&self, account_id: &str) -> ApiServiceResult; +} + +impl<'a> GetIncoming for TellerClient<'a> { + fn get_incoming(&self, account_id: &str) -> ApiServiceResult { + let account = try!(self.get_account(&account_id)); + let currency = account.currency; + + let from = UTC::today().with_day(1).unwrap(); + let transactions: Vec = self.raw_transactions(&account_id, 250, 1) + .unwrap_or(vec![]) + .into_iter() + .filter(|t| { + let transaction_date = + parse_utc_date_from_transaction(&t); + transaction_date > from + }) + .collect(); + + let from_float_string_to_cent_integer = |t: &Transaction| { + (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 + }; + let from_cent_integer_to_float_string = |amount: i64| format!("{:.2}", amount as f64 / 100f64); + + let incoming = transactions.iter() + .map(from_float_string_to_cent_integer) + .filter(|ci| *ci > 0) + .fold(0i64, |sum, v| sum + v); + + Ok(Money::new(from_cent_integer_to_float_string(incoming), currency)) + } +} diff --git a/src/api/inform/get_outgoing.rs b/src/api/inform/get_outgoing.rs new file mode 100644 index 0000000..ba04991 --- /dev/null +++ b/src/api/inform/get_outgoing.rs @@ -0,0 +1,40 @@ +use chrono::{UTC, Datelike}; + +use std::str::FromStr; // Use of #from_str. + +use super::{TellerClient, ApiServiceResult, Transaction, Money}; +use super::parse_utc_date_from_transaction; + +pub trait GetOutgoing { + fn get_outgoing(&self, account_id: &str) -> ApiServiceResult; +} + +impl<'a> GetOutgoing for TellerClient<'a> { + fn get_outgoing(&self, account_id: &str) -> ApiServiceResult { + let account = try!(self.get_account(&account_id)); + let currency = account.currency; + + let from = UTC::today().with_day(1).unwrap(); + let transactions: Vec = self.raw_transactions(&account_id, 250, 1) + .unwrap_or(vec![]) + .into_iter() + .filter(|t| { + let transaction_date = + parse_utc_date_from_transaction(&t); + transaction_date > from + }) + .collect(); + + let from_float_string_to_cent_integer = |t: &Transaction| { + (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 + }; + let from_cent_integer_to_float_string = |amount: i64| format!("{:.2}", amount as f64 / 100f64); + + let outgoing = transactions.iter() + .map(from_float_string_to_cent_integer) + .filter(|ci| *ci < 0) + .fold(0i64, |sum, v| sum + v); + + Ok(Money::new(from_cent_integer_to_float_string(outgoing.abs()), currency)) + } +} diff --git a/src/api/inform/get_transactions_with_currency.rs b/src/api/inform/get_transactions_with_currency.rs new file mode 100644 index 0000000..1bef5b9 --- /dev/null +++ b/src/api/inform/get_transactions_with_currency.rs @@ -0,0 +1,41 @@ +use cli::arg_types::Timeframe; + +use super::{TellerClient, ApiServiceResult, Transaction}; + +#[derive(Debug)] +pub struct TransactionsWithCurrrency { + pub transactions: Vec, + pub currency: String, +} + +impl TransactionsWithCurrrency { + pub fn new>(transactions: Vec, + currency: S) + -> TransactionsWithCurrrency { + TransactionsWithCurrrency { + transactions: transactions, + currency: currency.into(), + } + } +} + +pub trait GetTransactionsWithCurrency { + fn get_transactions_with_currency(&self, + account_id: &str, + timeframe: &Timeframe) + -> ApiServiceResult; +} + +impl<'a> GetTransactionsWithCurrency for TellerClient<'a> { + fn get_transactions_with_currency(&self, + account_id: &str, + timeframe: &Timeframe) + -> ApiServiceResult { + let transactions = try!(self.get_transactions(&account_id, &timeframe)); + + let account = try!(self.get_account(&account_id)); + let currency = account.currency; + + Ok(TransactionsWithCurrrency::new(transactions, currency)) + } +} diff --git a/src/api/inform/mod.rs b/src/api/inform/mod.rs new file mode 100644 index 0000000..a0dc674 --- /dev/null +++ b/src/api/inform/mod.rs @@ -0,0 +1,40 @@ +pub mod get_account_balance; +pub mod get_incoming; +pub mod get_outgoing; +pub mod get_transactions_with_currency; +pub mod get_counterparties; +pub mod get_aggregates; + +pub use super::client::{TellerClient, ApiServiceResult, Transaction, Account}; +pub use super::client::parse_utc_date_from_transaction; + +pub use self::get_account_balance::*; +pub use self::get_incoming::*; +pub use self::get_outgoing::*; +pub use self::get_transactions_with_currency::*; +pub use self::get_counterparties::*; +pub use self::get_aggregates::*; + +#[derive(Debug)] +pub struct Money { + amount: String, + currency: String, +} + +impl Money { + pub fn new>(amount: S, currency: S) -> Money { + Money { + amount: amount.into(), + currency: currency.into(), + } + } + + pub fn get_balance_for_display(&self, hide_currency: &bool) -> String { + if *hide_currency { + self.amount.to_owned() + } else { + let balance_with_currency = format!("{} {}", self.amount, self.currency); + balance_with_currency.to_owned() + } + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..cc1c9bf --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod error; +pub mod inform; + +pub use self::client::*; diff --git a/src/cli/arg_types.rs b/src/cli/arg_types.rs new file mode 100644 index 0000000..0f014a1 --- /dev/null +++ b/src/cli/arg_types.rs @@ -0,0 +1,26 @@ +#[derive(Debug)] +pub enum AccountType { + Current, + Savings, + Business, + Unknown(String), + None, +} + +#[derive(Debug)] +pub enum OutputFormat { + Spark, + Standard, +} + +#[derive(Debug)] +pub enum Interval { + Monthly, +} + +#[derive(Debug)] +pub enum Timeframe { + Year, + SixMonths, + ThreeMonths, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..c45ffe5 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,4 @@ +pub mod arg_types; +pub mod parse; + +pub use self::parse::{CommandType, CliArgs, get_command_type}; diff --git a/src/cli/parse.rs b/src/cli/parse.rs new file mode 100644 index 0000000..39186d5 --- /dev/null +++ b/src/cli/parse.rs @@ -0,0 +1,115 @@ +use rustc_serialize::{Decodable, Decoder}; + +use super::arg_types::{AccountType, OutputFormat, Interval, Timeframe}; + +#[derive(Debug, RustcDecodable)] +pub struct CliArgs { + cmd_init: bool, + cmd_list: bool, + cmd_show: bool, + cmd_accounts: bool, + cmd_transactions: bool, + cmd_counterparties: bool, + cmd_balances: bool, + cmd_outgoings: bool, + cmd_incomings: bool, + cmd_balance: bool, + cmd_outgoing: bool, + cmd_incoming: bool, + pub arg_account: AccountType, + pub flag_interval: Interval, + pub flag_timeframe: Timeframe, + pub flag_count: i64, + pub flag_show_description: bool, + pub flag_hide_currency: bool, + pub flag_output: OutputFormat, + flag_help: bool, + flag_version: bool, +} + +impl Decodable for AccountType { + fn decode(d: &mut D) -> Result { + let s = try!(d.read_str()); + let default_acccount_type = AccountType::None; + Ok(match &*s { + "" => default_acccount_type, + "current" => AccountType::Current, + "savings" => AccountType::Savings, + "business" => AccountType::Business, + s => AccountType::Unknown(s.to_string()), + }) + } +} + +impl Decodable for Interval { + fn decode(d: &mut D) -> Result { + let s = try!(d.read_str()); + let default_interval = Interval::Monthly; + Ok(match &*s { + "" => default_interval, + "monthly" => Interval::Monthly, + _ => { + error!("teller-cli currently only suports an interval of monthly"); + default_interval + } + }) + } +} + +impl Decodable for Timeframe { + fn decode(d: &mut D) -> Result { + let s = try!(d.read_str()); + let default_timeframe = Timeframe::SixMonths; + Ok(match &*s { + "year" => Timeframe::Year, + "6-months" => Timeframe::SixMonths, + "3-months" => Timeframe::ThreeMonths, + _ => default_timeframe, + }) + } +} + +impl Decodable for OutputFormat { + fn decode(d: &mut D) -> Result { + let s = try!(d.read_str()); + let default_output_format = OutputFormat::Standard; + Ok(match &*s { + "spark" => OutputFormat::Spark, + "standard" => OutputFormat::Standard, + _ => default_output_format, + }) + } +} + +#[derive(Debug)] +pub enum CommandType { + ShowUsage, + Initialise, + ListAccounts, + ShowBalance, + ShowOutgoing, + ShowIncoming, + ListTransactions, + ListCounterparties, + ListBalances, + ListOutgoings, + ListIncomings, + None, +} + +pub fn get_command_type(arguments: &CliArgs) -> CommandType { + match *arguments { + CliArgs { cmd_init, .. } if cmd_init => CommandType::Initialise, + CliArgs { cmd_accounts, .. } if cmd_accounts => CommandType::ListAccounts, + CliArgs { cmd_balance, .. } if cmd_balance => CommandType::ShowBalance, + CliArgs { cmd_outgoing, .. } if cmd_outgoing => CommandType::ShowOutgoing, + CliArgs { cmd_incoming, .. } if cmd_incoming => CommandType::ShowIncoming, + CliArgs { cmd_transactions, .. } if cmd_transactions => CommandType::ListTransactions, + CliArgs { cmd_counterparties, .. } if cmd_counterparties => CommandType::ListCounterparties, + CliArgs { cmd_balances, .. } if cmd_balances => CommandType::ListBalances, + CliArgs { cmd_incomings, .. } if cmd_incomings => CommandType::ListIncomings, + CliArgs { cmd_outgoings, .. } if cmd_outgoings => CommandType::ListOutgoings, + CliArgs { flag_help, flag_version, .. } if flag_help || flag_version => CommandType::None, + _ => CommandType::ShowUsage, + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs deleted file mode 100644 index f566e0c..0000000 --- a/src/client/mod.rs +++ /dev/null @@ -1,504 +0,0 @@ -pub mod error; - -use config::Config; - -use hyper::{Client, Url}; -use hyper::header::{Authorization, Bearer}; -use rustc_serialize::json; -use chrono::{Date, DateTime, UTC, Datelike}; -use chrono::duration::Duration; -use itertools::Itertools; - -use std::collections::HashMap; - -use std::io::prelude::*; // Required for read_to_string use later. -use std::str::FromStr; - -use self::error::TellerClientError; - -#[derive(Debug)] -pub enum Interval { - Monthly, -} - -#[derive(Debug)] -pub enum Timeframe { - Year, - SixMonths, - ThreeMonths, -} - -pub type ApiServiceResult = Result; -pub type IntervalAmount = (String, String); -pub type Balances = HistoricalAmountsWithCurrency; -pub type Outgoings = HistoricalAmountsWithCurrency; -pub type Incomings = HistoricalAmountsWithCurrency; - -#[derive(Debug)] -pub struct Money { - amount: String, - currency: String, -} - -impl Money { - - pub fn new>(amount: S, currency: S) -> Money { - Money { - amount: amount.into(), - currency: currency.into(), - } - } - - pub fn get_balance_for_display(&self, hide_currency: &bool) -> String { - if *hide_currency { - self.amount.to_owned() - } else { - let balance_with_currency = format!("{} {}", self.amount, self.currency); - balance_with_currency.to_owned() - } - } - -} - -#[derive(Debug, RustcDecodable)] -struct AccountResponse { - data: Account, -} - -#[derive(Debug, RustcDecodable)] -struct AccountsResponse { - data: Vec, -} - -#[derive(Debug, RustcDecodable)] -struct TransactionsResponse { - data: Vec, -} - -#[derive(Debug, RustcDecodable)] -pub struct Account { - pub updated_at: String, - pub institution: String, - pub id: String, - pub currency: String, - pub balance: String, - pub account_number_last_4: String, -} - -#[derive(Debug, RustcDecodable)] -pub struct Transaction { - pub description: String, - pub date: String, - pub counterparty: String, - pub amount: String, -} - -#[derive(Debug)] -pub struct HistoricalAmountsWithCurrency { - pub historical_amounts: Vec, - pub currency: String, -} - -impl HistoricalAmountsWithCurrency { - pub fn new>(historical_amounts: Vec, currency: S) -> HistoricalAmountsWithCurrency { - HistoricalAmountsWithCurrency { - historical_amounts: historical_amounts, - currency: currency.into(), - } - } -} - -#[derive(Debug)] -pub struct TransactionsWithCurrrency { - pub transactions: Vec, - pub currency: String, -} - -impl TransactionsWithCurrrency { - pub fn new>(transactions: Vec, currency: S) -> TransactionsWithCurrrency { - TransactionsWithCurrrency { - transactions: transactions, - currency: currency.into(), - } - } -} - -#[derive(Debug)] -pub struct CounterpartiesWithCurrrency { - pub counterparties: Vec<(String, String)>, - pub currency: String, -} - -impl CounterpartiesWithCurrrency { - pub fn new>(counterparties: Vec<(String, String)>, currency: S) -> CounterpartiesWithCurrrency { - CounterpartiesWithCurrrency { - counterparties: counterparties, - currency: currency.into(), - } - } -} - -fn get_auth_header(auth_token: &str) -> Authorization { - Authorization( - Bearer { - token: auth_token.to_string(), - } - ) -} - -pub fn get_accounts(config: &Config) -> ApiServiceResult> { - let client = Client::new(); - - let mut res = try!( - client.get("https://api.teller.io/accounts") - .header(get_auth_header(&config.auth_token)) - .send() - ); - if res.status.is_client_error() { - return Err(TellerClientError::AuthenticationError); - } - - let mut body = String::new(); - try!(res.read_to_string(&mut body)); - - debug!("GET /accounts response: {}", body); - - let accounts_response: AccountsResponse = try!(json::decode(&body)); - - Ok(accounts_response.data) -} - -pub fn get_account(config: &Config, account_id: &str) -> ApiServiceResult { - let client = Client::new(); - - let mut res = try!( - client.get(&format!("https://api.teller.io/accounts/{}", account_id)) - .header(get_auth_header(&config.auth_token)) - .send() - ); - if res.status.is_client_error() { - return Err(TellerClientError::AuthenticationError); - } - - let mut body = String::new(); - try!(res.read_to_string(&mut body)); - - debug!("GET /account/:id response: {}", body); - - let account_response: AccountResponse = try!(json::decode(&body)); - - Ok(account_response.data) -} - -pub fn get_account_balance(config: &Config, account_id: &str) -> ApiServiceResult { - let to_money = |a: Account| Money::new(a.balance, a.currency); - get_account(&config, &account_id).map(to_money) -} - -pub fn raw_transactions(config: &Config, account_id: &str, count: u32, page: u32) -> ApiServiceResult> { - let mut url = Url::parse(&format!("https://api.teller.io/accounts/{}/transactions", account_id)).unwrap(); - - const COUNT: &'static str = "count"; - const PAGE: &'static str = "page"; - let query = vec![ (COUNT, count.to_string()), (PAGE, page.to_string()) ]; - url.set_query_from_pairs(query.into_iter()); - - let client = Client::new(); - let mut res = try!( - client.get(url) - .header(get_auth_header(&config.auth_token)) - .send() - ); - if res.status.is_client_error() { - return Err(TellerClientError::AuthenticationError); - } - - let mut body = String::new(); - try!(res.read_to_string(&mut body)); - - debug!("GET /account/:id/transactions response: {}", body); - - let transactions_response: TransactionsResponse = try!(json::decode(&body)); - - Ok(transactions_response.data) -} - -fn parse_utc_date_from_transaction(t: &Transaction) -> Date { - let full_date = &(t.date.to_owned() + "T00:00:00-00:00"); - let past_transaction_date_without_tz = DateTime::parse_from_rfc3339(full_date).unwrap().date(); - let past_transaction_date = past_transaction_date_without_tz.with_timezone(&UTC); - past_transaction_date -} - -pub fn get_transactions(config: &Config, account_id: &str, timeframe: &Timeframe) -> ApiServiceResult> { - let page_through_transactions = |from| -> ApiServiceResult> { - let mut all_transactions = vec![]; - - let mut fetching = true; - let mut page = 1; - let count = 250; - while fetching { - let mut transactions = try!(raw_transactions(config, &account_id, count, page)); - match transactions.last() { - None => { - // If there are no transactions left, do not fetch forever... - fetching = false - }, - Some(past_transaction) => { - let past_transaction_date = parse_utc_date_from_transaction(&past_transaction); - if past_transaction_date < from { - fetching = false; - } - }, - }; - - all_transactions.append(&mut transactions); - page = page + 1; - } - - all_transactions = all_transactions.into_iter().filter(|t| { - let transaction_date = parse_utc_date_from_transaction(&t); - transaction_date > from - }).collect(); - - all_transactions.reverse(); - Ok(all_transactions) - }; - - match *timeframe { - Timeframe::ThreeMonths => { - let to = UTC::today(); - let from = to - Duration::days(91); // close enough... 😅 - - page_through_transactions(from) - }, - Timeframe::SixMonths => { - let to = UTC::today(); - let from = to - Duration::days(183); - - page_through_transactions(from) - }, - Timeframe::Year => { - let to = UTC::today(); - let from = to - Duration::days(365); - - page_through_transactions(from) - }, - } -} - -pub fn get_transactions_with_currency(config: &Config, account_id: &str, timeframe: &Timeframe) -> ApiServiceResult { - let transactions = try!(get_transactions(&config, &account_id, &timeframe)); - - let account = try!(get_account(&config, &account_id)); - let currency = account.currency; - - Ok(TransactionsWithCurrrency::new(transactions, currency)) -} - -fn convert_to_counterparty_to_date_amount_list<'a>(transactions: &'a Vec) -> HashMap> { - let grouped_counterparties = transactions.iter().fold(HashMap::new(), |mut acc: HashMap>, t: &'a Transaction| { - let counterparty = t.counterparty.to_owned(); - if acc.contains_key(&counterparty) { - if let Some(txs) = acc.get_mut(&counterparty) { - txs.push(t); - } - } else { - let mut txs: Vec<&'a Transaction> = vec![]; - txs.push(t); - acc.insert(counterparty, txs); - } - - acc - }); - - grouped_counterparties.into_iter().fold(HashMap::new(), |mut acc, (counterparty, txs)| { - let date_amount_tuples = txs.into_iter().map(|tx| (tx.date.to_owned(), tx.amount.to_owned())).collect(); - acc.insert(counterparty.to_string(), date_amount_tuples); - acc - }) -} - -pub fn get_counterparties(config: &Config, account_id: &str, timeframe: &Timeframe) -> ApiServiceResult { - let transactions_with_currency = try!(get_transactions_with_currency(&config, &account_id, &timeframe)); - - let to_cent_integer = |amount: &str| { - (f64::from_str(&amount).unwrap() * 100f64).round() as i64 - }; - let from_cent_integer_to_float_string = |amount: &i64| { - format!("{:.2}", *amount as f64 / 100f64) - }; - - let transactions: Vec = transactions_with_currency.transactions.into_iter().filter(|tx| to_cent_integer(&tx.amount) < 0).collect(); - let currency = transactions_with_currency.currency; - - let counterparty_to_date_amount_list = convert_to_counterparty_to_date_amount_list(&transactions); - let sorted_counterparties = counterparty_to_date_amount_list.into_iter().map(|(counterparty, date_amount_tuples)| { - let amount = date_amount_tuples.iter().fold(0i64, |acc, dat| acc + to_cent_integer(&dat.1)); - (counterparty, amount.abs()) - }).sort_by(|&(_, amount_a), &(_, amount_b)| { - amount_a.cmp(&amount_b) - }); - let counterparties = sorted_counterparties.into_iter().map(|(counterparty, amount)| { - (counterparty, from_cent_integer_to_float_string(&amount)) - }).collect(); - - Ok(CounterpartiesWithCurrrency::new(counterparties, currency)) -} - -fn get_grouped_transaction_aggregates(config: &Config, account_id: &str, interval: &Interval, timeframe: &Timeframe, aggregate_txs: &Fn((String, Vec)) -> (String, i64)) -> ApiServiceResult> { - let transactions: Vec = get_transactions(&config, &account_id, &timeframe).unwrap_or(vec![]); - - let mut month_year_grouped_transactions: Vec<(String, i64)> = transactions.into_iter().group_by(|t| { - let transaction_date = parse_utc_date_from_transaction(&t); - match *interval { - Interval::Monthly => { - let group_name = transaction_date.format("%m-%Y").to_string(); - group_name - } - } - }).map(aggregate_txs).collect(); - month_year_grouped_transactions.reverse(); - - Ok(month_year_grouped_transactions) -} - -pub fn get_balances(config: &Config, account_id: &str, interval: &Interval, timeframe: &Timeframe) -> ApiServiceResult { - let sum_all = |myt: (String, Vec)| { - let to_cent_integer = |t: &Transaction| { - (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 - }; - - let group_name = myt.0; - let amount = myt.1.iter().map(to_cent_integer).fold(0i64, |sum, v| sum + v); - (group_name, amount) - }; - - let month_year_total_transactions = try!(get_grouped_transaction_aggregates(&config, &account_id, &interval, &timeframe, &sum_all)); - - let account = try!(get_account(&config, &account_id)); - let current_balance = (f64::from_str(&account.balance).unwrap() * 100f64).round() as i64; - let currency = account.currency; - - let mut historical_amounts: Vec = vec![]; - historical_amounts.push(("current".to_string(), format!("{:.2}", current_balance as f64 / 100f64))); - - let mut last_balance = current_balance; - for mytt in month_year_total_transactions { - last_balance = last_balance - mytt.1; - historical_amounts.push((mytt.0.to_string(), format!("{:.2}", last_balance as f64 / 100f64))); - } - historical_amounts.reverse(); - - Ok(HistoricalAmountsWithCurrency::new(historical_amounts, currency)) -} - -pub fn get_outgoings(config: &Config, account_id: &str, interval: &Interval, timeframe: &Timeframe) -> ApiServiceResult { - let sum_outgoings = |myt: (String, Vec)| { - let to_cent_integer = |t: &Transaction| { - (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 - }; - - let group_name = myt.0; - let amount = myt.1.iter().map(to_cent_integer).filter(|ci| { - *ci < 0 - }).fold(0i64, |sum, v| sum + v); - (group_name, amount) - }; - - let month_year_total_outgoing = try!(get_grouped_transaction_aggregates(&config, &account_id, &interval, &timeframe, &sum_outgoings)); - - let account = try!(get_account(&config, &account_id)); - let currency = account.currency; - - let from_cent_integer_to_float_string = |amount: i64| { - format!("{:.2}", amount as f64 / 100f64) - }; - - let mut historical_amounts: Vec = vec![]; - for mytt in month_year_total_outgoing { - historical_amounts.push((mytt.0.to_string(), from_cent_integer_to_float_string(mytt.1.abs()))); - } - historical_amounts.reverse(); - - Ok(HistoricalAmountsWithCurrency::new(historical_amounts, currency)) -} - -pub fn get_incomings(config: &Config, account_id: &str, interval: &Interval, timeframe: &Timeframe) -> ApiServiceResult { - let sum_incomings = |myt: (String, Vec)| { - let to_cent_integer = |t: &Transaction| { - (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 - }; - - let group_name = myt.0; - let amount = myt.1.iter().map(to_cent_integer).filter(|ci| { - *ci > 0 - }).fold(0i64, |sum, v| sum + v); - (group_name, amount) - }; - - let month_year_total_incoming = try!(get_grouped_transaction_aggregates(&config, &account_id, &interval, &timeframe, &sum_incomings)); - - let account = try!(get_account(&config, &account_id)); - let currency = account.currency; - - let from_cent_integer_to_float_string = |amount: i64| { - format!("{:.2}", amount as f64 / 100f64) - }; - - let mut historical_amounts: Vec = vec![]; - for mytt in month_year_total_incoming { - historical_amounts.push((mytt.0.to_string(), from_cent_integer_to_float_string(mytt.1))); - } - historical_amounts.reverse(); - - Ok(HistoricalAmountsWithCurrency::new(historical_amounts, currency)) -} - -pub fn get_outgoing(config: &Config, account_id: &str) -> ApiServiceResult { - let account = try!(get_account(&config, &account_id)); - let currency = account.currency; - - let from = UTC::today().with_day(1).unwrap(); - let transactions: Vec = raw_transactions(&config, &account_id, 250, 1).unwrap_or(vec![]).into_iter().filter(|t| { - let transaction_date = parse_utc_date_from_transaction(&t); - transaction_date > from - }).collect(); - - let from_float_string_to_cent_integer = |t: &Transaction| { - (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 - }; - let from_cent_integer_to_float_string = |amount: i64| { - format!("{:.2}", amount as f64 / 100f64) - }; - - let outgoing = transactions.iter().map(from_float_string_to_cent_integer).filter(|ci| { - *ci < 0 - }).fold(0i64, |sum, v| sum + v); - - Ok(Money::new(from_cent_integer_to_float_string(outgoing.abs()), currency)) -} - -pub fn get_incoming(config: &Config, account_id: &str) -> ApiServiceResult { - let account = try!(get_account(&config, &account_id)); - let currency = account.currency; - - let from = UTC::today().with_day(1).unwrap(); - let transactions: Vec = raw_transactions(&config, &account_id, 250, 1).unwrap_or(vec![]).into_iter().filter(|t| { - let transaction_date = parse_utc_date_from_transaction(&t); - transaction_date > from - }).collect(); - - let from_float_string_to_cent_integer = |t: &Transaction| { - (f64::from_str(&t.amount).unwrap() * 100f64).round() as i64 - }; - let from_cent_integer_to_float_string = |amount: i64| { - format!("{:.2}", amount as f64 / 100f64) - }; - - let incoming = transactions.iter().map(from_float_string_to_cent_integer).filter(|ci| { - *ci > 0 - }).fold(0i64, |sum, v| sum + v); - - Ok(Money::new(from_cent_integer_to_float_string(incoming), currency)) -} diff --git a/src/command/initialise.rs b/src/command/initialise.rs new file mode 100644 index 0000000..40f10ba --- /dev/null +++ b/src/command/initialise.rs @@ -0,0 +1,101 @@ +use std::path::PathBuf; +use config::{Config, get_config_path, get_config_file_to_write, write_config}; +use inquirer::{Question, Answer, ask_question, ask_questions}; + +use api::TellerClient; +use super::representations::represent_list_accounts; + +pub fn configure_cli(config_file_path: &PathBuf) -> Option { + match ask_questions_for_config() { + None => None, + Some(config) => { + match get_config_file_to_write(&config_file_path) { + Ok(mut config_file) => { + let _ = write_config(&mut config_file, &config); + Some(config) + } + Err(e) => panic!("ERROR: opening file to write: {}", e), + } + } + } +} + +fn ask_questions_for_config() -> Option { + let get_auth_token_question = Question::new("auth_token", + "What is your `auth_token` on teller.io?"); + + let auth_token_answer = ask_question(&get_auth_token_question); + + let mut config = Config::new_with_auth_token_only(auth_token_answer.value); + + print!("\n"); + let accounts = { + let teller = TellerClient::new(&config.auth_token); + match teller.get_accounts() { + Ok(accounts) => accounts, + Err(e) => panic!("Unable to list accounts: {}", e), + } + }; + represent_list_accounts(&accounts, &config); + + println!("Please type the row (e.g. 3) of the account you wish to place against an alias and \ + press to set this in the config. Leave empty if irrelevant."); + print!("\n"); + + let questions = vec![ + Question::new( + "current", + "Which is your current account?", + ), + Question::new( + "savings", + "Which is your savings account?", + ), + Question::new( + "business", + "Which is your business account?", + ), + ]; + + let non_empty_answers = ask_questions(&questions); + let mut fa_iter = non_empty_answers.iter(); + + let to_account_id = |answer: &Answer| { + let row_number: u32 = answer.value + .parse() + .expect(&format!("ERROR: {:?} did not contain a number", + answer)); + accounts[(row_number - 1) as usize].id.to_owned() + }; + + match fa_iter.find(|&answer| answer.name == "current").map(&to_account_id) { + None => (), + Some(account_id) => config.current = account_id, + }; + match fa_iter.find(|&answer| answer.name == "savings").map(&to_account_id) { + None => (), + Some(account_id) => config.savings = account_id, + }; + match fa_iter.find(|&answer| answer.name == "business").map(&to_account_id) { + None => (), + Some(account_id) => config.business = account_id, + }; + + if config.auth_token.is_empty() { + error!("`auth_token` was invalid so a config could not be created"); + None + } else { + Some(config) + } +} + +pub fn initialise_command() -> i32 { + info!("Calling the initialise command"); + let config_file_path = get_config_path(); + println!("To create the config ({}) we need to find out your `auth_token` and assign aliases \ + to some common bank accounts.", + config_file_path.display()); + print!("\n"); + configure_cli(&config_file_path); + 0 +} diff --git a/src/command/list_accounts.rs b/src/command/list_accounts.rs new file mode 100644 index 0000000..b2bb84a --- /dev/null +++ b/src/command/list_accounts.rs @@ -0,0 +1,18 @@ +use config::Config; +use api::TellerClient; + +use super::representations::represent_list_accounts; + +pub fn list_accounts_command(config: &Config) -> i32 { + info!("Calling the list accounts command"); + let teller = TellerClient::new(&config.auth_token); + teller.get_accounts() + .map(|accounts| { + represent_list_accounts(&accounts, &config); + 0 + }) + .unwrap_or_else(|err| { + error!("Unable to list accounts: {}", err); + 1 + }) +} diff --git a/src/command/list_balances.rs b/src/command/list_balances.rs new file mode 100644 index 0000000..46e55fe --- /dev/null +++ b/src/command/list_balances.rs @@ -0,0 +1,30 @@ +use config::Config; +use api::TellerClient; +use api::inform::{Balances, GetBalances}; +use cli::arg_types::{AccountType, OutputFormat, Interval, Timeframe}; + +use super::representations::represent_list_amounts; + +fn represent_list_balances(hac: &Balances, output: &OutputFormat) { + represent_list_amounts("balance", &hac, &output) +} + +pub fn list_balances_command(config: &Config, + account: &AccountType, + interval: &Interval, + timeframe: &Timeframe, + output: &OutputFormat) + -> i32 { + info!("Calling the list balances command"); + let account_id = config.get_account_id(&account); + let teller = TellerClient::new(&config.auth_token); + teller.get_balances(&account_id, &interval, &timeframe) + .map(|balances| { + represent_list_balances(&balances, &output); + 0 + }) + .unwrap_or_else(|err| { + error!("Unable to list balances: {}", err); + 1 + }) +} diff --git a/src/command/list_counterparties.rs b/src/command/list_counterparties.rs new file mode 100644 index 0000000..794ec34 --- /dev/null +++ b/src/command/list_counterparties.rs @@ -0,0 +1,48 @@ +use config::Config; +use api::TellerClient; +use api::inform::GetCounterparties; +use cli::arg_types::{AccountType, Timeframe}; + +use super::representations::to_aligned_table; + +fn represent_list_counterparties(counterparties: &Vec<(String, String)>, + currency: &str, + count: &i64) { + let mut counterparties_table = String::new(); + + counterparties_table.push_str(&format!("row\tcounterparty\tamount ({})\n", currency)); + let skip_n = counterparties.len() - (*count as usize); + for (idx, counterparty) in counterparties.iter().skip(skip_n).enumerate() { + let row_number = (idx + 1) as u32; + let new_counterparty_row = format!("{}\t{}\t{}\n", + row_number, + counterparty.0, + counterparty.1); + counterparties_table = counterparties_table + &new_counterparty_row; + } + + let counterparties_str = to_aligned_table(&counterparties_table); + + print!("{}", counterparties_str) +} + +pub fn list_counterparties_command(config: &Config, + account: &AccountType, + timeframe: &Timeframe, + count: &i64) + -> i32 { + info!("Calling the list counterparties command"); + let account_id = config.get_account_id(&account); + let teller = TellerClient::new(&config.auth_token); + teller.get_counterparties(&account_id, &timeframe) + .map(|counterparties_with_currency| { + represent_list_counterparties(&counterparties_with_currency.counterparties, + &counterparties_with_currency.currency, + &count); + 0 + }) + .unwrap_or_else(|err| { + error!("Unable to list counterparties: {}", err); + 1 + }) +} diff --git a/src/command/list_incomings.rs b/src/command/list_incomings.rs new file mode 100644 index 0000000..edf69d0 --- /dev/null +++ b/src/command/list_incomings.rs @@ -0,0 +1,30 @@ +use config::Config; +use api::TellerClient; +use api::inform::{Incomings, GetIncomings}; +use cli::arg_types::{AccountType, OutputFormat, Interval, Timeframe}; + +use super::representations::represent_list_amounts; + +fn represent_list_incomings(hac: &Incomings, output: &OutputFormat) { + represent_list_amounts("incoming", &hac, &output) +} + +pub fn list_incomings_command(config: &Config, + account: &AccountType, + interval: &Interval, + timeframe: &Timeframe, + output: &OutputFormat) + -> i32 { + info!("Calling the list incomings command"); + let account_id = config.get_account_id(&account); + let teller = TellerClient::new(&config.auth_token); + teller.get_incomings(&account_id, &interval, &timeframe) + .map(|incomings| { + represent_list_incomings(&incomings, &output); + 0 + }) + .unwrap_or_else(|err| { + error!("Unable to list incomings: {}", err); + 1 + }) +} diff --git a/src/command/list_outgoings.rs b/src/command/list_outgoings.rs new file mode 100644 index 0000000..d85488c --- /dev/null +++ b/src/command/list_outgoings.rs @@ -0,0 +1,30 @@ +use config::Config; +use api::TellerClient; +use api::inform::{Outgoings, GetOutgoings}; +use cli::arg_types::{AccountType, OutputFormat, Interval, Timeframe}; + +use super::representations::represent_list_amounts; + +fn represent_list_outgoings(hac: &Outgoings, output: &OutputFormat) { + represent_list_amounts("outgoing", &hac, &output) +} + +pub fn list_outgoings_command(config: &Config, + account: &AccountType, + interval: &Interval, + timeframe: &Timeframe, + output: &OutputFormat) + -> i32 { + info!("Calling the list outgoings command"); + let account_id = config.get_account_id(&account); + let teller = TellerClient::new(&config.auth_token); + teller.get_outgoings(&account_id, &interval, &timeframe) + .map(|outgoings| { + represent_list_outgoings(&outgoings, &output); + 0 + }) + .unwrap_or_else(|err| { + error!("Unable to list outgoings: {}", err); + 1 + }) +} diff --git a/src/command/list_transactions.rs b/src/command/list_transactions.rs new file mode 100644 index 0000000..a4421c4 --- /dev/null +++ b/src/command/list_transactions.rs @@ -0,0 +1,63 @@ +use config::Config; +use api::{Transaction, TellerClient}; +use api::inform::{TransactionsWithCurrrency, GetTransactionsWithCurrency}; +use cli::arg_types::{AccountType, Timeframe}; + +use super::representations::to_aligned_table; + +fn represent_list_transactions(transactions: &Vec, + currency: &str, + show_description: &bool) { + let mut transactions_table = String::new(); + + if *show_description { + transactions_table.push_str(&format!("row\tdate\tcounterparty\tamount \ + ({})\tdescription\n", + currency)); + for (idx, transaction) in transactions.iter().enumerate() { + let row_number = (idx + 1) as u32; + let new_transaction_row = format!("{}\t{}\t{}\t{}\t{}\n", + row_number, + transaction.date, + transaction.counterparty, + transaction.amount, + transaction.description); + transactions_table = transactions_table + &new_transaction_row; + } + } else { + transactions_table.push_str(&format!("row\tdate\tcounterparty\tamount ({})\n", currency)); + for (idx, transaction) in transactions.iter().enumerate() { + let row_number = (idx + 1) as u32; + let new_transaction_row = format!("{}\t{}\t{}\t{}\n", + row_number, + transaction.date, + transaction.counterparty, + transaction.amount); + transactions_table = transactions_table + &new_transaction_row; + } + } + + let transactions_str = to_aligned_table(&transactions_table); + + print!("{}", transactions_str) +} + +pub fn list_transactions_command(config: &Config, + account: &AccountType, + timeframe: &Timeframe, + show_description: &bool) + -> i32 { + info!("Calling the list transactions command"); + let account_id = config.get_account_id(&account); + let teller = TellerClient::new(&config.auth_token); + teller.get_transactions_with_currency(&account_id, &timeframe) + .map(|transactions_with_currency| { + let TransactionsWithCurrrency { transactions, currency } = transactions_with_currency; + represent_list_transactions(&transactions, ¤cy, &show_description); + 0 + }) + .unwrap_or_else(|err| { + error!("Unable to list transactions: {}", err); + 1 + }) +} diff --git a/src/command/mod.rs b/src/command/mod.rs new file mode 100644 index 0000000..ee03788 --- /dev/null +++ b/src/command/mod.rs @@ -0,0 +1,147 @@ +mod representations; + +mod show_usage; +mod initialise; +mod list_accounts; +mod show_balance; +mod show_outgoing; +mod show_incoming; +mod list_transactions; +mod list_counterparties; +mod list_balances; +mod list_outgoings; +mod list_incomings; + +use cli::{CommandType, CliArgs}; + +use config::{Config, get_config, get_config_path}; +use self::initialise::configure_cli; + +use self::show_usage::show_usage_command; +use self::initialise::initialise_command; +use self::list_accounts::list_accounts_command; +use self::show_balance::show_balance_command; +use self::show_outgoing::show_outgoing_command; +use self::show_incoming::show_incoming_command; +use self::list_transactions::list_transactions_command; +use self::list_counterparties::list_counterparties_command; +use self::list_balances::list_balances_command; +use self::list_outgoings::list_outgoings_command; +use self::list_incomings::list_incomings_command; + +fn ensure_config() -> Option { + get_config().or_else(|| { + let config_file_path = get_config_path(); + println!("A config file could not be found at: {}", + config_file_path.display()); + println!("You will need to set the `auth_token` and give aliases to your bank accounts"); + print!("\n"); + configure_cli(&config_file_path) + }) +} + +fn do_nothing_command() -> i32 { + debug!("--help or --version were passed in so we are not going to execute anything more..."); + 0 +} + +pub fn execute(usage: &str, command_type: &CommandType, arguments: &CliArgs) -> i32 { + match *command_type { + CommandType::None => do_nothing_command(), + CommandType::ShowUsage => show_usage_command(usage), + CommandType::Initialise => initialise_command(), + _ => { + match ensure_config() { + None => { + error!("The command was not executed since a config could not be found or \ + generated"); + 1 + } + Some(config) => { + match *command_type { + CommandType::ListAccounts => list_accounts_command(&config), + CommandType::ShowBalance => { + let CliArgs { ref arg_account, flag_hide_currency, .. } = *arguments; + show_balance_command(&config, &arg_account, &flag_hide_currency) + } + CommandType::ShowOutgoing => { + let CliArgs { ref arg_account, flag_hide_currency, .. } = *arguments; + show_outgoing_command(&config, &arg_account, &flag_hide_currency) + } + CommandType::ShowIncoming => { + let CliArgs { ref arg_account, flag_hide_currency, .. } = *arguments; + show_incoming_command(&config, &arg_account, &flag_hide_currency) + } + CommandType::ListTransactions => { + let CliArgs { + ref arg_account, + flag_show_description, + ref flag_timeframe, + .. + } = *arguments; + list_transactions_command(&config, + &arg_account, + &flag_timeframe, + &flag_show_description) + } + CommandType::ListCounterparties => { + let CliArgs { + ref arg_account, + ref flag_timeframe, + flag_count, + .. + } = *arguments; + list_counterparties_command(&config, + &arg_account, + &flag_timeframe, + &flag_count) + } + CommandType::ListBalances => { + let CliArgs { + ref arg_account, + ref flag_interval, + ref flag_timeframe, + ref flag_output, + .. + } = *arguments; + list_balances_command(&config, + &arg_account, + &flag_interval, + &flag_timeframe, + &flag_output) + } + CommandType::ListOutgoings => { + let CliArgs { + ref arg_account, + ref flag_interval, + ref flag_timeframe, + ref flag_output, + .. + } = *arguments; + list_outgoings_command(&config, + &arg_account, + &flag_interval, + &flag_timeframe, + &flag_output) + } + CommandType::ListIncomings => { + let CliArgs { + ref arg_account, + ref flag_interval, + ref flag_timeframe, + ref flag_output, + .. + } = *arguments; + list_incomings_command(&config, + &arg_account, + &flag_interval, + &flag_timeframe, + &flag_output) + } + _ => panic!("This should not have been executable but for some reason was"), + } + } + } + } + } +} diff --git a/src/command/representations.rs b/src/command/representations.rs new file mode 100644 index 0000000..987391f --- /dev/null +++ b/src/command/representations.rs @@ -0,0 +1,70 @@ +use std::io::Write; +use tabwriter::TabWriter; + +use config::Config; +use api::Account; +use api::inform::HistoricalAmountsWithCurrency; +use cli::arg_types::OutputFormat; + +pub fn to_aligned_table(table_str: &str) -> String { + let mut tw = TabWriter::new(Vec::new()); + write!(&mut tw, "{}", table_str).unwrap(); + tw.flush().unwrap(); + + let aligned_table_str = String::from_utf8(tw.unwrap()).unwrap(); + + aligned_table_str +} + +pub fn represent_list_accounts(accounts: &Vec, config: &Config) { + let mut accounts_table = String::new(); + accounts_table.push_str("row\taccount no.\tbalance\n"); + for (idx, account) in accounts.iter().enumerate() { + let row_number = (idx + 1) as u32; + let account_alias = config.get_account_alias_for_id(&account.id); + let new_account_row = format!("{} {}\t****{}\t{}\t{}\n", + row_number, + account_alias, + account.account_number_last_4, + account.balance, + account.currency); + accounts_table = accounts_table + &new_account_row; + } + + let accounts_str = to_aligned_table(&accounts_table); + + print!("{}", accounts_str) +} + +pub fn represent_list_amounts(amount_type: &str, + hac: &HistoricalAmountsWithCurrency, + output: &OutputFormat) { + match *output { + OutputFormat::Spark => { + let balance_str = hac.historical_amounts + .iter() + .map(|b| b.1.to_owned()) + .collect::>() + .join(" "); + println!("{}", balance_str) + } + OutputFormat::Standard => { + let mut hac_table = String::new(); + let month_cols = hac.historical_amounts + .iter() + .map(|historical_amount| historical_amount.0.to_owned()) + .collect::>() + .join("\t"); + hac_table.push_str(&format!("\t{}\n", month_cols)); + hac_table.push_str(&format!("{} ({})", amount_type, hac.currency)); + for historical_amount in hac.historical_amounts.iter() { + let new_amount = format!("\t{}", historical_amount.1); + hac_table = hac_table + &new_amount; + } + + let hac_str = to_aligned_table(&hac_table); + + println!("{}", hac_str) + } + } +} diff --git a/src/command/show_balance.rs b/src/command/show_balance.rs new file mode 100644 index 0000000..7320b43 --- /dev/null +++ b/src/command/show_balance.rs @@ -0,0 +1,24 @@ +use api::TellerClient; +use api::inform::{Money, GetAccountBalance}; +use config::Config; +use cli::arg_types::AccountType; + +fn represent_money(money_with_currency: &Money, hide_currency: &bool) { + println!("{}", + money_with_currency.get_balance_for_display(&hide_currency)) +} + +pub fn show_balance_command(config: &Config, account: &AccountType, hide_currency: &bool) -> i32 { + info!("Calling the show balance command"); + let account_id = config.get_account_id(&account); + let teller = TellerClient::new(&config.auth_token); + teller.get_account_balance(&account_id) + .map(|balance| { + represent_money(&balance, &hide_currency); + 0 + }) + .unwrap_or_else(|err| { + error!("Unable to get account balance: {}", err); + 1 + }) +} diff --git a/src/command/show_incoming.rs b/src/command/show_incoming.rs new file mode 100644 index 0000000..e877bc1 --- /dev/null +++ b/src/command/show_incoming.rs @@ -0,0 +1,24 @@ +use api::TellerClient; +use api::inform::{Money, GetIncoming}; +use config::Config; +use cli::arg_types::AccountType; + +fn represent_money(money_with_currency: &Money, hide_currency: &bool) { + println!("{}", + money_with_currency.get_balance_for_display(&hide_currency)) +} + +pub fn show_incoming_command(config: &Config, account: &AccountType, hide_currency: &bool) -> i32 { + info!("Calling the show incoming command"); + let account_id = config.get_account_id(&account); + let teller = TellerClient::new(&config.auth_token); + teller.get_incoming(&account_id) + .map(|incoming| { + represent_money(&incoming, &hide_currency); + 0 + }) + .unwrap_or_else(|err| { + error!("Unable to get incoming: {}", err); + 1 + }) +} diff --git a/src/command/show_outgoing.rs b/src/command/show_outgoing.rs new file mode 100644 index 0000000..f0e2559 --- /dev/null +++ b/src/command/show_outgoing.rs @@ -0,0 +1,24 @@ +use api::TellerClient; +use api::inform::{Money, GetOutgoing}; +use config::Config; +use cli::arg_types::AccountType; + +fn represent_money(money_with_currency: &Money, hide_currency: &bool) { + println!("{}", + money_with_currency.get_balance_for_display(&hide_currency)) +} + +pub fn show_outgoing_command(config: &Config, account: &AccountType, hide_currency: &bool) -> i32 { + info!("Calling the show outgoing command"); + let account_id = config.get_account_id(&account); + let teller = TellerClient::new(&config.auth_token); + teller.get_outgoing(&account_id) + .map(|outgoing| { + represent_money(&outgoing, &hide_currency); + 0 + }) + .unwrap_or_else(|err| { + error!("Unable to get outgoing: {}", err); + 1 + }) +} diff --git a/src/command/show_usage.rs b/src/command/show_usage.rs new file mode 100644 index 0000000..bb959e5 --- /dev/null +++ b/src/command/show_usage.rs @@ -0,0 +1,5 @@ +pub fn show_usage_command(usage: &str) -> i32 { + info!("Calling the show usage command"); + print!("{}", usage); + 0 +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 5109580..eac05e2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,6 +11,8 @@ use std::io::prelude::*; // Required for read_to_string use later. use self::error::ConfigError; +use cli::arg_types::AccountType; + #[derive(Debug, RustcEncodable, RustcDecodable)] pub struct Config { pub auth_token: String, @@ -30,12 +32,32 @@ impl Config { } pub fn new_with_auth_token_only>(auth_token: S) -> Config { - Config::new( - auth_token.into(), - "".to_string(), - "".to_string(), - "".to_string(), - ) + Config::new(auth_token.into(), + "".to_string(), + "".to_string(), + "".to_string()) + } + + pub fn get_account_id(&self, account: &AccountType) -> String { + let default_account_id = self.current.to_owned(); + match *account { + AccountType::Current => self.current.to_owned(), + AccountType::Savings => self.savings.to_owned(), + AccountType::Business => self.business.to_owned(), + _ => default_account_id, + } + } + + pub fn get_account_alias_for_id<'a>(&self, account_id: &str) -> &'a str { + if *account_id == self.current { + "(current)" + } else if *account_id == self.savings { + "(savings)" + } else if *account_id == self.business { + "(business)" + } else { + "" + } } } @@ -49,13 +71,15 @@ pub fn get_config_path() -> PathBuf { } pub fn get_config_file(config_path: &PathBuf) -> Option { - info!("Checking whether config file within {} exists", config_path.to_str().unwrap()); + let config_path_str = config_path.to_str().unwrap_or("[error: config_path#to_str fails]"); + info!("Checking whether config file within {} exists", + config_path_str); let config_file = File::open(&config_path); match config_file { Err(ref e) if ErrorKind::NotFound == e.kind() => { - debug!("no config file found"); + debug!("No config file found"); None - }, + } Err(_) => panic!("Unable to read config!"), Ok(config_file) => Some(config_file), } @@ -64,11 +88,30 @@ pub fn get_config_file(config_path: &PathBuf) -> Option { pub fn get_config_file_to_write(config_path: &PathBuf) -> Result { let config_file = File::create(&config_path); match config_file { - Err(ref e) if ErrorKind::PermissionDenied == e.kind() => panic!("Permission to read config denied"), + Err(ref e) if ErrorKind::PermissionDenied == e.kind() => { + panic!("Permission to read config denied") + } _ => config_file, } } +pub fn get_config() -> Option { + let config_file_path = get_config_path(); + match get_config_file(&config_file_path) { + None => None, + Some(mut config_file) => { + match read_config(&mut config_file) { + Ok(config) => Some(config), + Err(e) => { + panic!("ERROR: attempting to read file {}: {}", + config_file_path.display(), + e) + } + } + } + } +} + pub fn read_config(config_file: &mut File) -> Result { let mut content_str = String::new(); try!(config_file.read_to_string(&mut content_str)); diff --git a/src/inquirer.rs b/src/inquirer/ask.rs similarity index 73% rename from src/inquirer.rs rename to src/inquirer/ask.rs index 3f69f8a..d495ae5 100644 --- a/src/inquirer.rs +++ b/src/inquirer/ask.rs @@ -34,7 +34,6 @@ impl Answer { } } - pub fn ask_question(question: &Question) -> Answer { let question_name = question.name.to_owned(); println!("{}", question.message); @@ -44,3 +43,11 @@ pub fn ask_question(question: &Question) -> Answer { Err(error) => panic!("Unable to read line for {}: {}", question_name, error), } } + +pub fn ask_questions(questions: &Vec) -> Vec { + let answers: Vec = questions.iter().map(ask_question).collect(); + let non_empty_answers: Vec = answers.into_iter() + .filter(|answer| !answer.value.is_empty()) + .collect(); + non_empty_answers +} diff --git a/src/inquirer/mod.rs b/src/inquirer/mod.rs new file mode 100644 index 0000000..8e50225 --- /dev/null +++ b/src/inquirer/mod.rs @@ -0,0 +1,3 @@ +pub mod ask; + +pub use self::ask::{Question, Answer, ask_question, ask_questions}; diff --git a/src/main.rs b/src/main.rs index c9ed328..37b4dfa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,24 +9,16 @@ extern crate hyper; extern crate tabwriter; extern crate itertools; +mod cli; +mod command; mod config; -mod client; mod inquirer; - -use client::{Account, Transaction, Money, HistoricalAmountsWithCurrency, Balances, Outgoings, Incomings, get_accounts, get_account_balance, get_transactions_with_currency, get_counterparties, get_balances, get_outgoings, get_incomings, get_outgoing, get_incoming}; -use client::{Interval, Timeframe}; - -use std::path::PathBuf; -use config::{Config, get_config_path, get_config_file, read_config, get_config_file_to_write, write_config}; - -use inquirer::{Question, Answer, ask_question}; +mod api; use docopt::Docopt; -use rustc_serialize::{Decodable, Decoder}; - -use std::io::Write; -use tabwriter::TabWriter; -use std::process::exit; +use cli::get_command_type; +use command::execute; +use std::process; const VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); const USAGE: &'static str = "Banking for the command line. @@ -67,536 +59,19 @@ Options: -o --output= Output in a particular format (e.g. spark). "; -#[derive(Debug, RustcDecodable)] -struct Args { - cmd_init: bool, - cmd_list: bool, - cmd_show: bool, - cmd_accounts: bool, - cmd_transactions: bool, - cmd_counterparties: bool, - cmd_balances: bool, - cmd_outgoings: bool, - cmd_incomings: bool, - cmd_balance: bool, - cmd_outgoing: bool, - cmd_incoming: bool, - arg_account: AccountType, - flag_interval: Interval, - flag_timeframe: Timeframe, - flag_count: i64, - flag_show_description: bool, - flag_hide_currency: bool, - flag_output: OutputFormat, - flag_help: bool, - flag_version: bool, -} - -#[derive(Debug)] -enum AccountType { - Current, - Savings, - Business, - Unknown(String), - None -} - -impl Decodable for AccountType { - fn decode(d: &mut D) -> Result { - let s = try!(d.read_str()); - let default_acccount_type = AccountType::None; - Ok(match &*s { - "" => default_acccount_type, - "current" => AccountType::Current, - "savings" => AccountType::Savings, - "business" => AccountType::Business, - s => AccountType::Unknown(s.to_string()), - }) - } -} - -impl Decodable for Interval { - fn decode(d: &mut D) -> Result { - let s = try!(d.read_str()); - let default_interval = Interval::Monthly; - Ok(match &*s { - "" => default_interval, - "monthly" => Interval::Monthly, - _ => { - error!("teller-cli currently only suports an interval of monthly"); - default_interval - }, - }) - } -} - -impl Decodable for Timeframe { - fn decode(d: &mut D) -> Result { - let s = try!(d.read_str()); - let default_timeframe = Timeframe::SixMonths; - Ok(match &*s { - "year" => Timeframe::Year, - "6-months" => Timeframe::SixMonths, - "3-months" => Timeframe::ThreeMonths, - _ => default_timeframe, - }) - } -} - -#[derive(Debug)] -enum OutputFormat { - Spark, - Standard, -} - -impl Decodable for OutputFormat { - fn decode(d: &mut D) -> Result { - let s = try!(d.read_str()); - let default_output_format = OutputFormat::Standard; - Ok(match &*s { - "spark" => OutputFormat::Spark, - "standard" => OutputFormat::Standard, - _ => default_output_format, - }) - } -} - -fn get_config() -> Option { - let config_file_path = get_config_path(); - match get_config_file(&config_file_path) { - None => { - println!("A config file could not be found at: {}", config_file_path.display()); - println!("You will need to set the `auth_token` and give aliases to your bank accounts"); - print!("\n"); - configure_cli(&config_file_path) - }, - Some(mut config_file) => { - match read_config(&mut config_file) { - Ok(config) => Some(config), - Err(e) => panic!("ERROR: attempting to read file {}: {}", config_file_path.display(), e), - } - }, - } -} - -fn configure_cli(config_file_path: &PathBuf) -> Option { - match init_config() { - None => None, - Some(config) => { - match get_config_file_to_write(&config_file_path) { - Ok(mut config_file) => { - let _ = write_config(&mut config_file, &config); - Some(config) - }, - Err(e) => panic!("ERROR: opening file to write: {}", e), - } - }, - } -} - -fn init_config() -> Option { - let get_auth_token_question = Question::new( - "auth_token", - "What is your `auth_token` on teller.io?", - ); - - let auth_token_answer = ask_question(&get_auth_token_question); - - let mut config = Config::new_with_auth_token_only(auth_token_answer.value); - - print!("\n"); - let accounts = match get_accounts(&config) { - Ok(accounts) => accounts, - Err(e) => panic!("Unable to list accounts: {}", e), - }; - represent_list_accounts(&accounts, &config); - - println!("Please type the row (e.g. 3) of the account you wish to place against an alias and press to set this in the config. Leave empty if irrelevant."); - print!("\n"); - - let questions = vec![ - Question::new( - "current", - "Which is your current account?", - ), - Question::new( - "savings", - "Which is your savings account?", - ), - Question::new( - "business", - "Which is your business account?", - ), - ]; - - let answers: Vec = questions.iter().map(ask_question).collect(); - let non_empty_answers: Vec<&Answer> = answers.iter().filter(|&answer| !answer.value.is_empty()).collect(); - let mut fa_iter = non_empty_answers.iter(); - - match fa_iter.find(|&answer| answer.name == "current") { - None => (), - Some(answer) => { - let row_number: u32 = answer.value.parse().expect(&format!("ERROR: {:?} did not contain a number", answer)); - config.current = accounts[(row_number - 1) as usize].id.to_owned() - }, - }; - match fa_iter.find(|&answer| answer.name == "savings") { - None => (), - Some(answer) => { - let row_number: u32 = answer.value.parse().expect(&format!("ERROR: {:?} did not contain a number", answer)); - config.savings = accounts[(row_number - 1) as usize].id.to_owned() - } - }; - match fa_iter.find(|&answer| answer.name == "business") { - None => (), - Some(answer) => { - let row_number: u32 = answer.value.parse().expect(&format!("ERROR: {:?} did not contain a number", answer)); - config.business = accounts[(row_number - 1) as usize].id.to_owned() - } - }; - - if config.auth_token.is_empty() { - error!("`auth_token` was invalid so a config could not be created"); - None - } else { - Some(config) - } -} - -fn pick_command(arguments: Args) { - match arguments { - Args { cmd_init, .. } if cmd_init == true => { - let config_file_path = get_config_path(); - println!("To create the config ({}) we need to find out your `auth_token` and assign aliases to some common bank accounts.", config_file_path.display()); - print!("\n"); - configure_cli(&config_file_path); - () - }, - Args { cmd_accounts, .. } if cmd_accounts == true => { - match get_config() { - None => { - error!("Configuration could not be found or created so command not executed"); - exit(1) - }, - Some(config) => list_accounts(&config), - } - }, - Args { cmd_balance, ref arg_account, flag_hide_currency, .. } if cmd_balance == true => { - match get_config() { - None => { - error!("Configuration could not be found or created so command not executed"); - exit(1) - } - Some(config) => show_balance(&config, &arg_account, &flag_hide_currency), - } - }, - Args { cmd_outgoing, ref arg_account, flag_hide_currency, .. } if cmd_outgoing == true => { - match get_config() { - None => { - error!("Configuration could not be found or created so command not executed"); - exit(1) - } - Some(config) => show_outgoing(&config, &arg_account, &flag_hide_currency), - } - }, - Args { cmd_incoming, ref arg_account, flag_hide_currency, .. } if cmd_incoming == true => { - match get_config() { - None => { - error!("Configuration could not be found or created so command not executed"); - exit(1) - } - Some(config) => show_incoming(&config, &arg_account, &flag_hide_currency), - } - }, - Args { cmd_transactions, ref arg_account, flag_show_description, ref flag_timeframe, .. } if cmd_transactions == true => { - match get_config() { - None => { - error!("Configuration could not be found or created so command not executed"); - exit(1) - } - Some(config) => list_transactions(&config, &arg_account, &flag_timeframe, &flag_show_description), - } - }, - Args { cmd_counterparties, ref arg_account, ref flag_timeframe, flag_count, .. } if cmd_counterparties == true => { - match get_config() { - None => { - error!("Configuration could not be found or created so command not executed"); - exit(1) - } - Some(config) => list_counterparties(&config, &arg_account, &flag_timeframe, &flag_count), - } - }, - Args { cmd_balances, ref arg_account, ref flag_interval, ref flag_timeframe, ref flag_output, .. } if cmd_balances == true => { - match get_config() { - None => { - error!("Configuration could not be found or created so command not executed"); - exit(1) - } - Some(config) => list_balances(&config, &arg_account, &flag_interval, &flag_timeframe, &flag_output), - } - }, - Args { cmd_incomings, ref arg_account, ref flag_interval, ref flag_timeframe, ref flag_output, .. } if cmd_incomings == true => { - match get_config() { - None => { - error!("Configuration could not be found or created so command not executed"); - exit(1) - } - Some(config) => list_incomings(&config, &arg_account, &flag_interval, &flag_timeframe, &flag_output), - } - }, - Args { cmd_outgoings, ref arg_account, ref flag_interval, ref flag_timeframe, ref flag_output, .. } if cmd_outgoings == true => { - match get_config() { - None => { - error!("Configuration could not be found or created so command not executed"); - exit(1) - } - Some(config) => list_outgoings(&config, &arg_account, &flag_interval, &flag_timeframe, &flag_output), - } - }, - Args { flag_help, flag_version, .. } if flag_help == true || flag_version == true => (), - _ => println!("{}", USAGE), - } -} - -fn get_account_alias_for_id<'a>(account_id: &str, config: &Config) -> &'a str { - if *account_id == config.current { - "(current)" - } else if *account_id == config.savings { - "(savings)" - } else if *account_id == config.business { - "(business)" - } else { - "" - } -} - -fn represent_list_accounts(accounts: &Vec, config: &Config) { - let mut accounts_table = String::new(); - accounts_table.push_str("row\taccount no.\tbalance\n"); - for (idx, account) in accounts.iter().enumerate() { - let row_number = (idx + 1) as u32; - let account_alias = get_account_alias_for_id(&account.id, &config); - let new_account_row = format!("{} {}\t****{}\t{}\t{}\n", row_number, account_alias, account.account_number_last_4, account.balance, account.currency); - accounts_table = accounts_table + &new_account_row; - } - - let mut tw = TabWriter::new(Vec::new()); - write!(&mut tw, "{}", accounts_table).unwrap(); - tw.flush().unwrap(); - - let accounts_str = String::from_utf8(tw.unwrap()).unwrap(); - - println!("{}", accounts_str) -} - -fn list_accounts(config: &Config) { - match get_accounts(&config) { - Ok(accounts) => represent_list_accounts(&accounts, &config), - Err(e) => { - error!("Unable to list accounts: {}", e); - exit(1) - }, - } -} - -fn represent_money(balance_with_currency: &Money, hide_currency: &bool) { - println!("{}", balance_with_currency.get_balance_for_display(&hide_currency)) -} - -fn get_account_id(config: &Config, account: &AccountType) -> String{ - let default_account_id = config.current.to_owned(); - match *account { - AccountType::Current => config.current.to_owned(), - AccountType::Savings => config.savings.to_owned(), - AccountType::Business => config.business.to_owned(), - _ => default_account_id, - } -} - -fn show_balance(config: &Config, account: &AccountType, hide_currency: &bool) { - let account_id = get_account_id(&config, &account); - match get_account_balance(&config, &account_id) { - Ok(balance) => represent_money(&balance, &hide_currency), - Err(e) => { - error!("Unable to get account balance: {}", e); - exit(1) - }, - } -} - -fn show_outgoing(config: &Config, account: &AccountType, hide_currency: &bool) { - let account_id = get_account_id(&config, &account); - match get_outgoing(&config, &account_id) { - Ok(outgoing) => represent_money(&outgoing, &hide_currency), - Err(e) => { - error!("Unable to get outgoing: {}", e); - exit(1) - }, - } -} - -fn show_incoming(config: &Config, account: &AccountType, hide_currency: &bool) { - let account_id = get_account_id(&config, &account); - match get_incoming(&config, &account_id) { - Ok(incoming) => represent_money(&incoming, &hide_currency), - Err(e) => { - error!("Unable to get incoming: {}", e); - exit(1) - }, - } -} - -fn represent_list_transactions(transactions: &Vec, currency: &str, show_description: &bool) { - let mut transactions_table = String::new(); - - if *show_description { - transactions_table.push_str(&format!("row\tdate\tcounterparty\tamount ({})\tdescription\n", currency)); - for (idx, transaction) in transactions.iter().enumerate() { - let row_number = (idx + 1) as u32; - let new_transaction_row = format!("{}\t{}\t{}\t{}\t{}\n", row_number, transaction.date, transaction.counterparty, transaction.amount, transaction.description); - transactions_table = transactions_table + &new_transaction_row; - } - } else { - transactions_table.push_str(&format!("row\tdate\tcounterparty\tamount ({})\n", currency)); - for (idx, transaction) in transactions.iter().enumerate() { - let row_number = (idx + 1) as u32; - let new_transaction_row = format!("{}\t{}\t{}\t{}\n", row_number, transaction.date, transaction.counterparty, transaction.amount); - transactions_table = transactions_table + &new_transaction_row; - } - } - - let mut tw = TabWriter::new(Vec::new()); - write!(&mut tw, "{}", transactions_table).unwrap(); - tw.flush().unwrap(); - - let transactions_str = String::from_utf8(tw.unwrap()).unwrap(); - - println!("{}", transactions_str) -} - -fn list_transactions(config: &Config, account: &AccountType, timeframe: &Timeframe, show_description: &bool) { - let account_id = get_account_id(&config, &account); - match get_transactions_with_currency(&config, &account_id, &timeframe) { - Ok(transactions_with_currency) => represent_list_transactions(&transactions_with_currency.transactions, &transactions_with_currency.currency, &show_description), - Err(e) => { - error!("Unable to list transactions: {}", e); - exit(1) - }, - } -} - -fn represent_list_counterparties(counterparties: &Vec<(String, String)>, currency: &str, count: &i64) { - let mut counterparties_table = String::new(); - - counterparties_table.push_str(&format!("row\tcounterparty\tamount ({})\n", currency)); - let skip_n = counterparties.len() - (*count as usize); - for (idx, counterparty) in counterparties.iter().skip(skip_n).enumerate() { - let row_number = (idx + 1) as u32; - let new_counterparty_row = format!("{}\t{}\t{}\n", row_number, counterparty.0, counterparty.1); - counterparties_table = counterparties_table + &new_counterparty_row; - } - - let mut tw = TabWriter::new(Vec::new()); - write!(&mut tw, "{}", counterparties_table).unwrap(); - tw.flush().unwrap(); - - let counterparties_str = String::from_utf8(tw.unwrap()).unwrap(); - - println!("{}", counterparties_str) -} - -fn list_counterparties(config: &Config, account: &AccountType, timeframe: &Timeframe, count: &i64) { - let account_id = get_account_id(&config, &account); - match get_counterparties(&config, &account_id, &timeframe) { - Ok(counterparties_with_currency) => represent_list_counterparties(&counterparties_with_currency.counterparties, &counterparties_with_currency.currency, &count), - Err(e) => { - error!("Unable to list counterparties: {}", e); - exit(1) - }, - } -} - -fn represent_list_amounts(amount_type: &str, hac: &HistoricalAmountsWithCurrency, output: &OutputFormat) { - match *output { - OutputFormat::Spark => { - let balance_str = hac.historical_amounts.iter().map(|b| b.1.to_owned()).collect::>().join(" "); - println!("{}", balance_str) - }, - OutputFormat::Standard => { - let mut hac_table = String::new(); - let month_cols = hac.historical_amounts.iter().map(|historical_amount| historical_amount.0.to_owned()).collect::>().join("\t"); - hac_table.push_str(&format!("\t{}\n", month_cols)); - hac_table.push_str(&format!("{} ({})", amount_type, hac.currency)); - for historical_amount in hac.historical_amounts.iter() { - let new_amount = format!("\t{}", historical_amount.1); - hac_table = hac_table + &new_amount; - } - - let mut tw = TabWriter::new(Vec::new()); - write!(&mut tw, "{}", hac_table).unwrap(); - tw.flush().unwrap(); - - let hac_str = String::from_utf8(tw.unwrap()).unwrap(); - - println!("{}", hac_str) - }, - } -} - -fn represent_list_balances(hac: &Balances, output: &OutputFormat) { - represent_list_amounts("balance", &hac, &output) -} - -fn list_balances(config: &Config, account: &AccountType, interval: &Interval, timeframe: &Timeframe, output: &OutputFormat) { - let account_id = get_account_id(&config, &account); - match get_balances(&config, &account_id, &interval, &timeframe) { - Ok(balances) => represent_list_balances(&balances, &output), - Err(e) => { - error!("Unable to list balances: {}", e); - exit(1) - }, - } -} - -fn represent_list_outgoings(hac: &Outgoings, output: &OutputFormat) { - represent_list_amounts("outgoing", &hac, &output) -} - -fn list_outgoings(config: &Config, account: &AccountType, interval: &Interval, timeframe: &Timeframe, output: &OutputFormat) { - let account_id = get_account_id(&config, &account); - match get_outgoings(&config, &account_id, &interval, &timeframe) { - Ok(outgoings) => represent_list_outgoings(&outgoings, &output), - Err(e) => { - error!("Unable to list ougoings: {}", e); - exit(1) - }, - } -} - -fn represent_list_incomings(hac: &Incomings, output: &OutputFormat) { - represent_list_amounts("incoming", &hac, &output) -} - -fn list_incomings(config: &Config, account: &AccountType, interval: &Interval, timeframe: &Timeframe, output: &OutputFormat) { - let account_id = get_account_id(&config, &account); - match get_incomings(&config, &account_id, &interval, &timeframe) { - Ok(incomings) => represent_list_incomings(&incomings, &output), - Err(e) => { - error!("Unable to list incomings: {}", e); - exit(1) - }, - } -} - fn main() { env_logger::init().unwrap(); - let arguments: Args = Docopt::new(USAGE) - .and_then(|d| { - d.version(VERSION.map(|v| v.to_string())) - .decode() - }) - .unwrap_or_else(|e| e.exit()); + let arguments = Docopt::new(USAGE) + .and_then(|d| { + d.version(VERSION.map(|v| v.to_string())) + .decode() + }) + .unwrap_or_else(|e| e.exit()); + + let command_type = get_command_type(&arguments); + + let return_code = execute(USAGE, &command_type, &arguments); - pick_command(arguments) + process::exit(return_code) }