diff --git a/src/csv.rs b/src/csv.rs new file mode 100644 index 0000000..cd311f6 --- /dev/null +++ b/src/csv.rs @@ -0,0 +1,56 @@ +use crate::Transaction; +use csv::ReaderBuilder; +use std::error::Error; +use std::io::{Read, Write}; + +pub fn output_csv(output: W, transactions: &[Transaction]) -> Result<(), Box> { + let mut writer = csv::Writer::from_writer(output); + + writer + .write_record(["Date", "Payee", "Memo", "Amount"]) + .unwrap(); + for transaction in transactions { + let output = transaction.to_record(); + writer.write_record(output).unwrap(); + } + writer.flush().unwrap(); + Ok(()) +} + +enum BankFormat { + Bulder, +} + +impl BankFormat { + fn from_str(format: &str) -> Result { + match format { + "bulder" => Ok(BankFormat::Bulder), + _ => Err(format!("Bank '{}' not found", format)), + } + } + + fn reader(&self, input: R) -> csv::Reader { + match self { + BankFormat::Bulder => ReaderBuilder::new() + .has_headers(true) + .delimiter(b';') + .from_reader(input), + } + } + + fn transform(&self, record: &csv::StringRecord) -> Result { + match self { + BankFormat::Bulder => Transaction::from_fields(&record[0], &record[9], &record[1]), + } + } +} + +pub fn read_transactions(input: R, format: &str) -> Result, String> { + let format = BankFormat::from_str(format).unwrap(); + format + .reader(input) + .records() + .map(|r| r.map_err(|e| format!("parse error: {}", e))) + .map(|r| r.and_then(|r| format.transform(&r))) + .collect::, _>>() +} diff --git a/src/lib.rs b/src/lib.rs index 314d3a1..7e87c7e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,43 +1,4 @@ mod transaction; pub use transaction::Transaction; +pub mod csv; pub mod ynab; -use csv::ReaderBuilder; -use std::io::Read; - -enum BankFormat { - Bulder, -} - -impl BankFormat { - fn from_str(format: &str) -> Result { - match format { - "bulder" => Ok(BankFormat::Bulder), - _ => Err(format!("Bank '{}' not found", format)), - } - } - - fn reader(&self, input: R) -> csv::Reader { - match self { - BankFormat::Bulder => ReaderBuilder::new() - .has_headers(true) - .delimiter(b';') - .from_reader(input), - } - } - - fn transform(&self, record: &csv::StringRecord) -> Result { - match self { - BankFormat::Bulder => Transaction::from_fields(&record[0], &record[9], &record[1]), - } - } -} - -pub fn read_transactions(input: R, format: &str) -> Result, String> { - let format = BankFormat::from_str(format).unwrap(); - format - .reader(input) - .records() - .map(|r| r.map_err(|e| format!("parse error: {}", e))) - .map(|r| r.and_then(|r| format.transform(&r))) - .collect::, _>>() -} diff --git a/src/main.rs b/src/main.rs index d8f3ac7..0c6dd57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ use clap::{Parser, Subcommand}; -//use directories::ProjectDirs; use std::{ error::Error, fs::File, @@ -7,102 +6,83 @@ use std::{ path::PathBuf, }; -use ynabmunger::ynab; -use ynabmunger::{Transaction, read_transactions}; - -fn output_csv(transactions: &[Transaction]) -> Result<(), Box> { - let mut writer = csv::Writer::from_writer(stdout()); - - writer - .write_record(["Date", "Payee", "Memo", "Amount"]) - .unwrap(); - for transaction in transactions { - let output = transaction.to_record(); - writer.write_record(output).unwrap(); - } - writer.flush().unwrap(); - Ok(()) -} - -#[derive(Debug)] -enum Output { - Csv, - Ynab { - token: String, - plan: ynab::Lookup, - account: ynab::Lookup, - }, -} +use ynabmunger::Transaction; +use ynabmunger::csv::{output_csv, read_transactions}; +use ynabmunger::ynab::{Lookup, Ynab}; #[derive(Subcommand)] enum Command { - Csv { - inputs: Vec, + /// List available plans in YNAB + Plans { + /// Your YNAB token, available from developer settings in YNAB + #[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)] + token: String, }, - Ynab { - #[arg(short, long, env = "YNAB_API_TOKEN")] + /// List available accounts in a given plan in YNAB + Accounts { + /// Your YNAB token, available from developer settings in YNAB + #[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)] token: String, - #[arg(short, long, help = "Plan name", group = "planref")] + /// The name of the YNAB plan to list + #[arg(short, long, group = "planref")] plan: Option, - #[arg(short = 'P', long, help = "Plan ID", group = "planref")] + /// Alternatively, give the YNAB plan ID directly + #[arg(short = 'P', long, group = "planref")] + plan_id: Option, + }, + /// Convert from a bank export to a CSV you can import manually to YNAB + Convert { + #[arg(short, long, help = "Bank export format", default_value_t = String::from("bulder"))] + format: String, + + inputs: Vec, + }, + /// Read a bank export and import it directly to an account in ynab + Import { + /// Your YNAB token, available from developer settings in YNAB + #[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)] + token: String, + + /// The name of the YNAB plan to import to + #[arg(short, long, group = "planref")] + plan: Option, + + /// Alternatively, give the YNAB plan ID directly + #[arg(short = 'P', long, group = "planref")] plan_id: Option, - #[arg(short, long, help = "Account name", group = "accountref")] + /// The name of the YNAB account to import to + #[arg(short, long, group = "accountref")] account: Option, - #[arg(short = 'A', long, help = "Account ID", group = "accountref")] + /// Alternatively, give the YNAB account ID directly + #[arg(short = 'A', long, group = "accountref")] account_id: Option, + /// The format of the bank export you are importing + /// + /// This is different for every bank, and sometimes even the same + /// bank can have different formats for different account types. + #[arg(short, long, default_value_t = String::from("bulder"))] + format: String, + inputs: Vec, }, } #[derive(Parser)] struct Cli { - #[arg(short, long, help = "Bank export format", default_value_t = String::from("bulder"))] - format: String, - #[command(subcommand)] command: Command, } -fn main() -> Result<(), Box> { - let cli = Cli::parse(); - - // Inject config file stuff here - - let inputs: Vec; - let output = match cli.command { - Command::Ynab { - plan, - plan_id, - account_id, - account, - inputs: inp, - token, - } => { - inputs = inp; - - let output_plan = ynab::Lookup::try_from((plan, plan_id))?; - let output_account = ynab::Lookup::try_from((account, account_id))?; - - Output::Ynab { - token, - plan: output_plan, - account: output_account, - } - } - Command::Csv { inputs: inp } => { - inputs = inp; - Output::Csv - } - }; - - // Config file reconciliation here? - - let transactions: Vec = if inputs.is_empty() { +fn read_transactions_from( + inputs: &[PathBuf], + format: &str, +) -> Result, Box> { + Ok(if inputs.is_empty() { vec![Box::new(stdin()) as Box] } else { inputs @@ -111,18 +91,57 @@ fn main() -> Result<(), Box> { .collect::, std::io::Error>>()? } .iter_mut() - .map(|s| read_transactions(s, &cli.format)) + .map(|s| read_transactions(s, format)) .collect::, _>>()? .into_iter() .flatten() - .collect(); + .collect()) +} - match output { - Output::Ynab { +fn main() -> Result<(), Box> { + let cli = Cli::parse(); + + match cli.command { + Command::Plans { token } => { + let plans = Ynab::new(&token).list_plans()?; + println!("Available plans:"); + for plan in plans { + println!(" - {}", plan); + } + Ok(()) + } + Command::Accounts { token, plan, + plan_id, + } => { + let plan = Lookup::try_from((plan, plan_id))?; + let accounts = Ynab::new(&token).list_accounts(plan)?; + println!("Available accounts in plan:"); + for account in accounts { + println!(" - {}", account); + } + Ok(()) + } + Command::Import { + token, + plan, + plan_id, + account_id, account, - } => ynab::Ynab::new(&token, plan, account)?.upload(&transactions), - Output::Csv => output_csv(&transactions), + format, + inputs, + } => { + let plan = Lookup::try_from((plan, plan_id))?; + let account = Lookup::try_from((account, account_id))?; + + let transactions = read_transactions_from(&inputs, &format)?; + + Ynab::new(&token).upload(&transactions, plan, account) + } + Command::Convert { format, inputs } => { + let transactions = read_transactions_from(&inputs, &format)?; + output_csv(stdout(), &transactions) + } } } diff --git a/src/ynab.rs b/src/ynab.rs index bbdebbe..b4fe59b 100644 --- a/src/ynab.rs +++ b/src/ynab.rs @@ -43,14 +43,32 @@ struct YnabTransactionList { transactions: Vec, } -struct YnabClient { +#[derive(Debug)] +pub enum Lookup { + Name(String), + Id(String), +} + +impl TryFrom<(Option, Option)> for Lookup { + type Error = String; + + fn try_from((name, id): (Option, Option)) -> Result { + match (name, id) { + (Some(name), _) => Ok(Lookup::Name(name)), + (_, Some(id)) => Ok(Lookup::Id(id)), + _ => Err("must provide name or id".to_owned()), + } + } +} + +pub struct Ynab { token: String, webclient: reqwest::blocking::Client, } -impl YnabClient { - fn new(token: &str) -> YnabClient { - YnabClient { +impl Ynab { + pub fn new(token: &str) -> Ynab { + Ynab { token: token.to_owned(), webclient: reqwest::blocking::Client::new(), } @@ -130,50 +148,35 @@ impl YnabClient { )) } } -} -#[derive(Debug)] -pub enum Lookup { - Name(String), - Id(String), -} - -impl TryFrom<(Option, Option)> for Lookup { - type Error = String; - - fn try_from((name, id): (Option, Option)) -> Result { - match (name, id) { - (Some(name), _) => Ok(Lookup::Name(name)), - (_, Some(id)) => Ok(Lookup::Id(id)), - _ => Err("must provide name or id".to_owned()), - } - } -} - -pub struct Ynab { - client: YnabClient, - plan_id: String, - account_id: String, -} - -impl Ynab { - pub fn new(token: &str, plan: Lookup, account: Lookup) -> Result { - let client = YnabClient::new(token); - let plan_id = client.resolve_plan(plan)?; - let account_id = client.resolve_account(&plan_id, account)?; - Ok(Ynab { - client, - plan_id, - account_id, - }) + pub fn list_plans(&self) -> Result, String> { + let url = String::from("plans"); + let res: YnabResponse = serde_json::from_str( + &self + .get(&url) + .map_err(|e| format!("request error: {}", e))?, + ) + .map_err(|e| format!("parse error: {}", e))?; + Ok(res.data.plans.into_iter().map(|p| p.name).collect()) } - pub fn dump(&self, transactions: &[Transaction]) { - println!("{:?}", self.ynab_transactions(transactions)); + pub fn list_accounts(&self, plan: Lookup) -> Result, String> { + let plan_id = self.resolve_plan(plan)?; + let url = format!("plans/{}/accounts", plan_id); + let res: YnabResponse = serde_json::from_str( + &self + .get(&url) + .map_err(|e| format!("request error: {}", e))?, + ) + .map_err(|e| format!("parse error: {}", e))?; + Ok(res.data.accounts.into_iter().map(|p| p.name).collect()) } - // YNAB:-294230:2015-12-30:1 - fn ynab_transactions(&self, transactions: &[Transaction]) -> Vec { + fn ynab_transactions( + &self, + transactions: &[Transaction], + account_id: &str, + ) -> Vec { let mut result = Vec::new(); let mut idmap: HashMap<(String, i64), u32> = HashMap::new(); for t in transactions { @@ -188,22 +191,28 @@ impl Ynab { date: t.date.clone(), amount: t.amount, payee_name: t.payee.clone(), - account_id: self.account_id.clone(), + account_id: account_id.to_string(), }; result.push(y); } - result } - pub fn upload(&self, transactions: &[Transaction]) -> Result<(), Box> { - let request = format!("plans/{}/transactions", self.plan_id); + pub fn upload( + &self, + transactions: &[Transaction], + plan: Lookup, + account: Lookup, + ) -> Result<(), Box> { + let plan_id = self.resolve_plan(plan)?; + let account_id = self.resolve_account(&plan_id, account)?; + let request = format!("plans/{}/transactions", plan_id); let body = serde_json::to_string(&YnabTransactionList { - transactions: self.ynab_transactions(transactions), + transactions: self.ynab_transactions(transactions, &account_id), }) .map_err(|e| format!("transaction format error: {}", e))?; println!("{}", body); - self.client.post(&request, &body)?; + self.post(&request, &body)?; Ok(()) } }