Initial import of just-about-working version
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1969
Cargo.lock
generated
Normal file
1969
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "ynabmunger"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||||
|
csv = "1.4.0"
|
||||||
|
directories = "6.0.0"
|
||||||
|
reqwest = { version = "0.13.3", features = ["blocking"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
43
src/lib.rs
Normal file
43
src/lib.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
mod transaction;
|
||||||
|
pub use transaction::Transaction;
|
||||||
|
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<_>, _>>()
|
||||||
|
}
|
||||||
141
src/main.rs
Normal file
141
src/main.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
//use directories::ProjectDirs;
|
||||||
|
use std::{
|
||||||
|
error::Error,
|
||||||
|
fs::File,
|
||||||
|
io::{Read, stdin, stdout},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
|
use ynabmunger::ynab;
|
||||||
|
use ynabmunger::{Transaction, read_transactions};
|
||||||
|
|
||||||
|
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)]
|
||||||
|
enum Command {
|
||||||
|
Csv {
|
||||||
|
inputs: Vec<PathBuf>,
|
||||||
|
},
|
||||||
|
Ynab {
|
||||||
|
#[arg(short, long, env = "YNAB_API_TOKEN")]
|
||||||
|
token: String,
|
||||||
|
|
||||||
|
#[arg(short, long, help = "Plan name", group = "planref")]
|
||||||
|
plan: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short = 'P', long, help = "Plan ID", group = "planref")]
|
||||||
|
plan_id: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short, long, help = "Account name", group = "accountref")]
|
||||||
|
account: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short = 'A', long, help = "Account ID", group = "accountref")]
|
||||||
|
account_id: Option<String>,
|
||||||
|
|
||||||
|
inputs: Vec<PathBuf>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<dyn Error>> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Inject config file stuff here
|
||||||
|
|
||||||
|
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 = if let Some(plan) = plan {
|
||||||
|
Ok(ynab::Lookup::Name(plan))
|
||||||
|
} else if let Some(plan_id) = plan_id {
|
||||||
|
Ok(ynab::Lookup::Id(plan_id))
|
||||||
|
} else {
|
||||||
|
Err("no plan name or id".to_string())
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let output_account = if let Some(account) = account {
|
||||||
|
Ok(ynab::Lookup::Name(account))
|
||||||
|
} else if let Some(account_id) = account_id {
|
||||||
|
Ok(ynab::Lookup::Id(account_id))
|
||||||
|
} else {
|
||||||
|
Err("no account name or id".to_string())
|
||||||
|
}?;
|
||||||
|
|
||||||
|
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>]
|
||||||
|
} else {
|
||||||
|
inputs
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| -> Result<Box<dyn Read>, _> { Ok(Box::new(File::open(p)?)) })
|
||||||
|
.collect::<Result<Vec<_>, std::io::Error>>()?
|
||||||
|
}
|
||||||
|
.iter_mut()
|
||||||
|
.map(|s| read_transactions(s, &cli.format))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Output::Ynab {
|
||||||
|
token,
|
||||||
|
plan,
|
||||||
|
account,
|
||||||
|
} => ynab::Ynab::new(&token, plan, account)?.upload(&transactions),
|
||||||
|
Output::Csv => output_csv(&transactions),
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/transaction.rs
Normal file
49
src/transaction.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
pub struct Transaction {
|
||||||
|
pub date: String,
|
||||||
|
pub payee: String,
|
||||||
|
pub amount: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_amount_str(amount: &str) -> Result<i64, String> {
|
||||||
|
if amount.is_empty() {
|
||||||
|
return Err("empty amount".to_string());
|
||||||
|
}
|
||||||
|
let (whole, frac) = amount.split_once('.').unwrap_or((amount, "00"));
|
||||||
|
let whole = whole
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|e| format!("parse error on whole {}: {}", whole, e))?;
|
||||||
|
let frac = frac
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|e| format!("parse error on fraction {}: {}", frac, e))?;
|
||||||
|
|
||||||
|
let mut amount: i64 = 0;
|
||||||
|
amount += whole * 100 * 10;
|
||||||
|
amount += if whole < 0 { -frac } else { frac } * 10;
|
||||||
|
Ok(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transaction {
|
||||||
|
/// Construct a Transaction from [date, payee, amount] strings
|
||||||
|
pub fn from_fields(date: &str, payee: &str, amount: &str) -> Result<Transaction, String> {
|
||||||
|
Ok(Transaction {
|
||||||
|
date: date.to_owned(),
|
||||||
|
payee: payee.to_owned(),
|
||||||
|
amount: parse_amount_str(amount)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_amount(&self) -> String {
|
||||||
|
let whole = self.amount / 10 / 100;
|
||||||
|
let frac = if self.amount < 0 { -1 } else { 1 } * (self.amount / 10) % 100;
|
||||||
|
format!("{}.{:02}", whole, frac)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_record(&self) -> [String; 4] {
|
||||||
|
[
|
||||||
|
self.date.to_string(),
|
||||||
|
self.payee.to_string(),
|
||||||
|
String::new(),
|
||||||
|
self.format_amount(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
197
src/ynab.rs
Normal file
197
src/ynab.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
use crate::Transaction;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct YnabTransaction {
|
||||||
|
import_id: String,
|
||||||
|
date: String,
|
||||||
|
amount: i64,
|
||||||
|
payee_name: String,
|
||||||
|
account_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
struct YnabResponse<T> {
|
||||||
|
data: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct YnabPlanList {
|
||||||
|
plans: Vec<YnabPlan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct YnabPlan {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct YnabAccountList {
|
||||||
|
accounts: Vec<YnabAccount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct YnabAccount {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
struct YnabTransactionList {
|
||||||
|
transactions: Vec<YnabTransaction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct YnabClient {
|
||||||
|
token: String,
|
||||||
|
webclient: reqwest::blocking::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YnabClient {
|
||||||
|
fn new(token: &str) -> YnabClient {
|
||||||
|
YnabClient {
|
||||||
|
token: token.to_owned(),
|
||||||
|
webclient: reqwest::blocking::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_plan(&self, plan: Lookup) -> Result<String, String> {
|
||||||
|
match plan {
|
||||||
|
Lookup::Id(id) => Ok(id),
|
||||||
|
Lookup::Name(name) => {
|
||||||
|
let url = "plans";
|
||||||
|
let res: YnabResponse<YnabPlanList> = serde_json::from_str(
|
||||||
|
&self.get(url).map_err(|e| format!("request error: {}", e))?,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("parse error: {}", e))?;
|
||||||
|
let plans = res.data.plans;
|
||||||
|
plans
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.name == name)
|
||||||
|
.map(|p| p.id)
|
||||||
|
.ok_or_else(|| "no matching plan found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_account(&self, plan_id: &str, account: Lookup) -> Result<String, String> {
|
||||||
|
match account {
|
||||||
|
Lookup::Id(id) => Ok(id),
|
||||||
|
Lookup::Name(name) => {
|
||||||
|
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))?;
|
||||||
|
let accounts = res.data.accounts;
|
||||||
|
accounts
|
||||||
|
.into_iter()
|
||||||
|
.find(|a| a.name == name)
|
||||||
|
.map(|a| a.id)
|
||||||
|
.ok_or_else(|| "no matching account found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, request: &str) -> Result<String, reqwest::Error> {
|
||||||
|
const URL: &str = "https://api.ynab.com/v1/";
|
||||||
|
let request = format!("{}{}", URL, request);
|
||||||
|
let res = self
|
||||||
|
.webclient
|
||||||
|
.get(request)
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.send()?;
|
||||||
|
let text = res.text()?;
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(&self, request: &str, body: &str) -> Result<(), String> {
|
||||||
|
const URL: &str = "https://api.ynab.com/v1/";
|
||||||
|
let request = format!("{}{}", URL, request);
|
||||||
|
let res = self
|
||||||
|
.webclient
|
||||||
|
.post(request)
|
||||||
|
.body(body.to_owned())
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("post error: {}", e))?;
|
||||||
|
if res.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"api error: {}: {}",
|
||||||
|
res.status(),
|
||||||
|
res.text()
|
||||||
|
.map_err(|e| format!("error retrieving api error: {}", e))?
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Lookup {
|
||||||
|
Name(String),
|
||||||
|
Id(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
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]) {
|
||||||
|
println!("{:?}", self.ynab_transactions(transactions));
|
||||||
|
}
|
||||||
|
|
||||||
|
// YNAB:-294230:2015-12-30:1
|
||||||
|
fn ynab_transactions(&self, transactions: &[Transaction]) -> Vec<YnabTransaction> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut idmap: HashMap<(String, i64), u32> = HashMap::new();
|
||||||
|
for t in transactions {
|
||||||
|
let key = (t.date.clone(), t.amount);
|
||||||
|
let id = {
|
||||||
|
let n = idmap.entry(key).or_insert(0);
|
||||||
|
*n += 1;
|
||||||
|
*n
|
||||||
|
};
|
||||||
|
let y = YnabTransaction {
|
||||||
|
import_id: format!("YNAB:{}:{}:{}", t.amount, t.date, id),
|
||||||
|
date: t.date.clone(),
|
||||||
|
amount: t.amount,
|
||||||
|
payee_name: t.payee.clone(),
|
||||||
|
account_id: self.account_id.clone(),
|
||||||
|
};
|
||||||
|
result.push(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upload(&self, transactions: &[Transaction]) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let request = format!("plans/{}/transactions", self.plan_id);
|
||||||
|
let body = serde_json::to_string(&YnabTransactionList {
|
||||||
|
transactions: self.ynab_transactions(transactions),
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("transaction format error: {}", e))?;
|
||||||
|
println!("{}", body);
|
||||||
|
self.client.post(&request, &body)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user