Refactor and add new commands and doc comments
This commit is contained in:
56
src/csv.rs
Normal file
56
src/csv.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use crate::Transaction;
|
||||||
|
use csv::ReaderBuilder;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
pub fn output_csv<W: Write>(output: W, transactions: &[Transaction]) -> Result<(), Box<dyn Error>> {
|
||||||
|
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<BankFormat, String> {
|
||||||
|
match format {
|
||||||
|
"bulder" => Ok(BankFormat::Bulder),
|
||||||
|
_ => Err(format!("Bank '{}' not found", format)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reader<R: Read>(&self, input: R) -> csv::Reader<R> {
|
||||||
|
match self {
|
||||||
|
BankFormat::Bulder => ReaderBuilder::new()
|
||||||
|
.has_headers(true)
|
||||||
|
.delimiter(b';')
|
||||||
|
.from_reader(input),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform(&self, record: &csv::StringRecord) -> Result<Transaction, String> {
|
||||||
|
match self {
|
||||||
|
BankFormat::Bulder => Transaction::from_fields(&record[0], &record[9], &record[1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_transactions<R: Read>(input: R, format: &str) -> Result<Vec<Transaction>, 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::<Result<Vec<_>, _>>()
|
||||||
|
}
|
||||||
41
src/lib.rs
41
src/lib.rs
@@ -1,43 +1,4 @@
|
|||||||
mod transaction;
|
mod transaction;
|
||||||
pub use transaction::Transaction;
|
pub use transaction::Transaction;
|
||||||
|
pub mod csv;
|
||||||
pub mod ynab;
|
pub mod ynab;
|
||||||
use csv::ReaderBuilder;
|
|
||||||
use std::io::Read;
|
|
||||||
|
|
||||||
enum BankFormat {
|
|
||||||
Bulder,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BankFormat {
|
|
||||||
fn from_str(format: &str) -> Result<BankFormat, String> {
|
|
||||||
match format {
|
|
||||||
"bulder" => Ok(BankFormat::Bulder),
|
|
||||||
_ => Err(format!("Bank '{}' not found", format)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reader<R: Read>(&self, input: R) -> csv::Reader<R> {
|
|
||||||
match self {
|
|
||||||
BankFormat::Bulder => ReaderBuilder::new()
|
|
||||||
.has_headers(true)
|
|
||||||
.delimiter(b';')
|
|
||||||
.from_reader(input),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transform(&self, record: &csv::StringRecord) -> Result<Transaction, String> {
|
|
||||||
match self {
|
|
||||||
BankFormat::Bulder => Transaction::from_fields(&record[0], &record[9], &record[1]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_transactions<R: Read>(input: R, format: &str) -> Result<Vec<Transaction>, 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::<Result<Vec<_>, _>>()
|
|
||||||
}
|
|
||||||
|
|||||||
177
src/main.rs
177
src/main.rs
@@ -1,5 +1,4 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
//use directories::ProjectDirs;
|
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
fs::File,
|
fs::File,
|
||||||
@@ -7,102 +6,83 @@ use std::{
|
|||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
};
|
};
|
||||||
|
|
||||||
use ynabmunger::ynab;
|
use ynabmunger::Transaction;
|
||||||
use ynabmunger::{Transaction, read_transactions};
|
use ynabmunger::csv::{output_csv, read_transactions};
|
||||||
|
use ynabmunger::ynab::{Lookup, Ynab};
|
||||||
fn output_csv(transactions: &[Transaction]) -> Result<(), Box<dyn Error>> {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Command {
|
enum Command {
|
||||||
Csv {
|
/// List available plans in YNAB
|
||||||
inputs: Vec<PathBuf>,
|
Plans {
|
||||||
|
/// Your YNAB token, available from developer settings in YNAB
|
||||||
|
#[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)]
|
||||||
|
token: String,
|
||||||
},
|
},
|
||||||
Ynab {
|
/// List available accounts in a given plan in YNAB
|
||||||
#[arg(short, long, env = "YNAB_API_TOKEN")]
|
Accounts {
|
||||||
|
/// Your YNAB token, available from developer settings in YNAB
|
||||||
|
#[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)]
|
||||||
token: String,
|
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<String>,
|
plan: Option<String>,
|
||||||
|
|
||||||
#[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<String>,
|
||||||
|
},
|
||||||
|
/// 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<PathBuf>,
|
||||||
|
},
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
|
/// Alternatively, give the YNAB plan ID directly
|
||||||
|
#[arg(short = 'P', long, group = "planref")]
|
||||||
plan_id: Option<String>,
|
plan_id: Option<String>,
|
||||||
|
|
||||||
#[arg(short, long, help = "Account name", group = "accountref")]
|
/// The name of the YNAB account to import to
|
||||||
|
#[arg(short, long, group = "accountref")]
|
||||||
account: Option<String>,
|
account: Option<String>,
|
||||||
|
|
||||||
#[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<String>,
|
account_id: Option<String>,
|
||||||
|
|
||||||
|
/// 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<PathBuf>,
|
inputs: Vec<PathBuf>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[arg(short, long, help = "Bank export format", default_value_t = String::from("bulder"))]
|
|
||||||
format: String,
|
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn read_transactions_from(
|
||||||
let cli = Cli::parse();
|
inputs: &[PathBuf],
|
||||||
|
format: &str,
|
||||||
// Inject config file stuff here
|
) -> Result<Vec<Transaction>, Box<dyn Error>> {
|
||||||
|
Ok(if inputs.is_empty() {
|
||||||
let inputs: Vec<PathBuf>;
|
|
||||||
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<Transaction> = if inputs.is_empty() {
|
|
||||||
vec![Box::new(stdin()) as Box<dyn Read>]
|
vec![Box::new(stdin()) as Box<dyn Read>]
|
||||||
} else {
|
} else {
|
||||||
inputs
|
inputs
|
||||||
@@ -111,18 +91,57 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
.collect::<Result<Vec<_>, std::io::Error>>()?
|
.collect::<Result<Vec<_>, std::io::Error>>()?
|
||||||
}
|
}
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.map(|s| read_transactions(s, &cli.format))
|
.map(|s| read_transactions(s, format))
|
||||||
.collect::<Result<Vec<_>, _>>()?
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.collect();
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
match output {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
Output::Ynab {
|
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,
|
token,
|
||||||
plan,
|
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,
|
account,
|
||||||
} => ynab::Ynab::new(&token, plan, account)?.upload(&transactions),
|
format,
|
||||||
Output::Csv => output_csv(&transactions),
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/ynab.rs
107
src/ynab.rs
@@ -43,14 +43,32 @@ struct YnabTransactionList {
|
|||||||
transactions: Vec<YnabTransaction>,
|
transactions: Vec<YnabTransaction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct YnabClient {
|
#[derive(Debug)]
|
||||||
|
pub enum Lookup {
|
||||||
|
Name(String),
|
||||||
|
Id(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<(Option<String>, Option<String>)> for Lookup {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from((name, id): (Option<String>, Option<String>)) -> Result<Self, Self::Error> {
|
||||||
|
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,
|
token: String,
|
||||||
webclient: reqwest::blocking::Client,
|
webclient: reqwest::blocking::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl YnabClient {
|
impl Ynab {
|
||||||
fn new(token: &str) -> YnabClient {
|
pub fn new(token: &str) -> Ynab {
|
||||||
YnabClient {
|
Ynab {
|
||||||
token: token.to_owned(),
|
token: token.to_owned(),
|
||||||
webclient: reqwest::blocking::Client::new(),
|
webclient: reqwest::blocking::Client::new(),
|
||||||
}
|
}
|
||||||
@@ -130,50 +148,35 @@ impl YnabClient {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
pub fn list_plans(&self) -> Result<Vec<String>, String> {
|
||||||
pub enum Lookup {
|
let url = String::from("plans");
|
||||||
Name(String),
|
let res: YnabResponse<YnabPlanList> = serde_json::from_str(
|
||||||
Id(String),
|
&self
|
||||||
}
|
.get(&url)
|
||||||
|
.map_err(|e| format!("request error: {}", e))?,
|
||||||
impl TryFrom<(Option<String>, Option<String>)> for Lookup {
|
)
|
||||||
type Error = String;
|
.map_err(|e| format!("parse error: {}", e))?;
|
||||||
|
Ok(res.data.plans.into_iter().map(|p| p.name).collect())
|
||||||
fn try_from((name, id): (Option<String>, Option<String>)) -> Result<Self, Self::Error> {
|
|
||||||
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<Ynab, String> {
|
|
||||||
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 dump(&self, transactions: &[Transaction]) {
|
pub fn list_accounts(&self, plan: Lookup) -> Result<Vec<String>, String> {
|
||||||
println!("{:?}", self.ynab_transactions(transactions));
|
let plan_id = self.resolve_plan(plan)?;
|
||||||
|
let url = format!("plans/{}/accounts", plan_id);
|
||||||
|
let res: YnabResponse<YnabAccountList> = 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(
|
||||||
fn ynab_transactions(&self, transactions: &[Transaction]) -> Vec<YnabTransaction> {
|
&self,
|
||||||
|
transactions: &[Transaction],
|
||||||
|
account_id: &str,
|
||||||
|
) -> Vec<YnabTransaction> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut idmap: HashMap<(String, i64), u32> = HashMap::new();
|
let mut idmap: HashMap<(String, i64), u32> = HashMap::new();
|
||||||
for t in transactions {
|
for t in transactions {
|
||||||
@@ -188,22 +191,28 @@ impl Ynab {
|
|||||||
date: t.date.clone(),
|
date: t.date.clone(),
|
||||||
amount: t.amount,
|
amount: t.amount,
|
||||||
payee_name: t.payee.clone(),
|
payee_name: t.payee.clone(),
|
||||||
account_id: self.account_id.clone(),
|
account_id: account_id.to_string(),
|
||||||
};
|
};
|
||||||
result.push(y);
|
result.push(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upload(&self, transactions: &[Transaction]) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn upload(
|
||||||
let request = format!("plans/{}/transactions", self.plan_id);
|
&self,
|
||||||
|
transactions: &[Transaction],
|
||||||
|
plan: Lookup,
|
||||||
|
account: Lookup,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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 {
|
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))?;
|
.map_err(|e| format!("transaction format error: {}", e))?;
|
||||||
println!("{}", body);
|
println!("{}", body);
|
||||||
self.client.post(&request, &body)?;
|
self.post(&request, &body)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user