2 Commits

Author SHA1 Message Date
james deda785c2a Fix API interaction and add transactions command 2026-05-02 23:12:49 +02:00
james 65ddf515ad Start refactor of ynab by splitting it up 2026-05-02 11:04:38 +02:00
8 changed files with 307 additions and 96 deletions
Generated
+97
View File
@@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -58,6 +67,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.3"
@@ -128,6 +143,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.1"
@@ -527,6 +555,30 @@ dependencies = [
"windows-registry",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "icu_collections"
version = "2.2.0"
@@ -795,6 +847,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1644,6 +1705,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -1851,6 +1947,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
name = "ynabmunger"
version = "0.1.0"
dependencies = [
"chrono",
"clap",
"csv",
"directories",
+1
View File
@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.44"
clap = { version = "4.6.1", features = ["derive", "env"] }
csv = "1.4.0"
directories = "6.0.0"
+58 -4
View File
@@ -32,6 +32,31 @@ enum Command {
#[arg(short = 'P', long, group = "planref")]
plan_id: Option<String>,
},
// List transactions in the last 30 days
//
// You have to give a plan to list transactions from. You can optionally
// also give an account to show only transactions from that account.
Transactions {
/// 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 list transactions from
#[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>,
/// The name of the YNAB account to import to
#[arg(short, long, group = "accountref")]
account: Option<String>,
/// Alternatively, give the YNAB account ID directly
#[arg(short = 'A', long, group = "accountref")]
account_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"))]
@@ -115,14 +140,43 @@ fn main() -> Result<(), Box<dyn Error>> {
plan,
plan_id,
} => {
let plan = Lookup::try_from((plan, plan_id))?;
let accounts = Ynab::new(&token).list_accounts(plan)?;
let plan = Lookup::from_options(plan, plan_id)?;
let accounts = Ynab::list_accounts(&Ynab::new(&token), plan)?;
println!("Available accounts in plan:");
for account in accounts {
println!(" - {}", account);
}
Ok(())
}
Command::Transactions {
token,
plan,
plan_id,
account,
account_id,
} => {
let plan = Lookup::from_options(plan, plan_id)?;
// Account is optional here
let accountstr: &str;
let account = match (account, account_id) {
(None, None) => {
accountstr = "";
None
}
(name, id) => {
accountstr = " for account";
Some(Lookup::from_options(name, id)?)
}
};
let transactions = Ynab::new(&token).list_transactions(plan, account)?;
println!("Transactions{} in the last 30 days:", accountstr);
for t in transactions {
println!("{},{},{}", t.date, t.payee, t.amount);
}
Ok(())
}
Command::Import {
token,
plan,
@@ -132,8 +186,8 @@ fn main() -> Result<(), Box<dyn Error>> {
format,
inputs,
} => {
let plan = Lookup::try_from((plan, plan_id))?;
let account = Lookup::try_from((account, account_id))?;
let plan = Lookup::from_options(plan, plan_id)?;
let account = Lookup::from_options(account, account_id)?;
let transactions = read_transactions_from(&inputs, &format)?;
+35 -92
View File
@@ -1,65 +1,7 @@
use super::lookup::Lookup;
use super::transform;
use super::types;
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>,
}
#[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,
@@ -79,7 +21,7 @@ impl Ynab {
Lookup::Id(id) => Ok(id),
Lookup::Name(name) => {
let url = "plans";
let res: YnabResponse<YnabPlanList> = serde_json::from_str(
let res: types::YnabResponse<types::YnabPlanList> = serde_json::from_str(
&self.get(url).map_err(|e| format!("request error: {}", e))?,
)
.map_err(|e| format!("parse error: {}", e))?;
@@ -98,7 +40,7 @@ impl Ynab {
Lookup::Id(id) => Ok(id),
Lookup::Name(name) => {
let url = format!("plans/{}/accounts", plan_id);
let res: YnabResponse<YnabAccountList> = serde_json::from_str(
let res: types::YnabResponse<types::YnabAccountList> = serde_json::from_str(
&self
.get(&url)
.map_err(|e| format!("request error: {}", e))?,
@@ -114,7 +56,7 @@ impl Ynab {
}
}
fn get(&self, request: &str) -> Result<String, reqwest::Error> {
pub(crate) 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
@@ -138,6 +80,12 @@ impl Ynab {
.send()
.map_err(|e| format!("post error: {}", e))?;
if res.status().is_success() {
println!(
"successful api response {}: {}",
res.status(),
res.text()
.map_err(|e| format!("error retrieving api error: {}", e))?
);
Ok(())
} else {
Err(format!(
@@ -151,7 +99,7 @@ impl Ynab {
pub fn list_plans(&self) -> Result<Vec<String>, String> {
let url = String::from("plans");
let res: YnabResponse<YnabPlanList> = serde_json::from_str(
let res: types::YnabResponse<types::YnabPlanList> = serde_json::from_str(
&self
.get(&url)
.map_err(|e| format!("request error: {}", e))?,
@@ -163,7 +111,7 @@ impl Ynab {
pub fn list_accounts(&self, plan: Lookup) -> Result<Vec<String>, String> {
let plan_id = self.resolve_plan(plan)?;
let url = format!("plans/{}/accounts", plan_id);
let res: YnabResponse<YnabAccountList> = serde_json::from_str(
let res: types::YnabResponse<types::YnabAccountList> = serde_json::from_str(
&self
.get(&url)
.map_err(|e| format!("request error: {}", e))?,
@@ -172,30 +120,26 @@ impl Ynab {
Ok(res.data.accounts.into_iter().map(|p| p.name).collect())
}
fn ynab_transactions(
pub fn list_transactions(
&self,
transactions: &[Transaction],
account_id: &str,
) -> 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: account_id.to_string(),
};
result.push(y);
}
result
plan: Lookup,
account: Option<Lookup>,
) -> Result<Vec<Transaction>, String> {
let plan_id = self.resolve_plan(plan)?;
let account_id = match account {
Some(account) => Some(self.resolve_account(&plan_id, account)?),
None => None,
};
let since_date = chrono::Local::now().date_naive() - chrono::Duration::days(30);
let since_date = since_date.format("%Y-%m-%d");
let url = format!("plans/{}/transactions?since_date={}", plan_id, since_date);
let raw = self.get(&url).map_err(|e| format!("request error {}", e))?;
let res: types::YnabResponse<types::YnabTransactionList> =
serde_json::from_str(&raw).map_err(|e| format!("parse error: {}", e))?;
Ok(transform::from_ynab_transactions(
&res.data.transactions,
account_id.as_deref(),
))
}
pub fn upload(
@@ -207,11 +151,10 @@ impl Ynab {
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, &account_id),
let body = serde_json::to_string(&types::YnabTransactionList {
transactions: transform::to_ynab_transactions(transactions, &account_id),
})
.map_err(|e| format!("transaction format error: {}", e))?;
println!("{}", body);
self.post(&request, &body)?;
Ok(())
}
+14
View File
@@ -0,0 +1,14 @@
pub enum Lookup {
Name(String),
Id(String),
}
impl Lookup {
pub fn from_options(name: Option<String>, id: Option<String>) -> Result<Self, String> {
match (name, id) {
(Some(name), _) => Ok(Self::Name(name)),
(_, Some(id)) => Ok(Self::Id(id)),
_ => Err("must provide name or id".to_owned()),
}
}
}
+9
View File
@@ -0,0 +1,9 @@
mod lookup;
pub use lookup::Lookup;
mod types;
mod client;
pub use client::Ynab;
mod transform;
+50
View File
@@ -0,0 +1,50 @@
use super::types;
use crate::Transaction;
use std::collections::HashMap;
pub(crate) fn to_ynab_transactions(
transactions: &[Transaction],
account_id: &str,
) -> Vec<types::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 = types::YnabTransaction {
import_id: Some(format!("YNAB:{}:{}:{}", t.amount, t.date, id)),
date: t.date.clone(),
amount: t.amount,
payee_name: Some(t.payee.clone()),
account_id: account_id.to_string(),
cleared: "cleared".to_string(),
};
result.push(y);
}
result
}
pub(crate) fn from_ynab_transactions(
transactions: &[types::YnabTransaction],
account_id: Option<&str>,
) -> Vec<Transaction> {
transactions
.iter()
.filter(|t| match account_id {
Some(id) => t.account_id == id,
None => true,
})
.map(|t| Transaction {
date: t.date.to_owned(),
payee: match &t.payee_name {
Some(name) => name.to_owned(),
None => String::new(),
},
amount: t.amount,
})
.collect()
}
+43
View File
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Debug, Serialize)]
pub(crate) struct YnabTransaction {
pub(crate) import_id: Option<String>,
pub(crate) date: String,
pub(crate) amount: i64,
pub(crate) payee_name: Option<String>,
pub(crate) account_id: String,
pub(crate) cleared: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub(crate) struct YnabResponse<T> {
pub(crate) data: T,
}
#[derive(Deserialize, Debug)]
pub(crate) struct YnabPlanList {
pub(crate) plans: Vec<YnabPlan>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct YnabPlan {
pub(crate) id: String,
pub(crate) name: String,
}
#[derive(Deserialize, Debug)]
pub(crate) struct YnabAccountList {
pub(crate) accounts: Vec<YnabAccount>,
}
#[derive(Deserialize, Debug)]
pub(crate) struct YnabAccount {
pub(crate) id: String,
pub(crate) name: String,
}
#[derive(Deserialize, Serialize, Debug)]
pub(crate) struct YnabTransactionList {
pub(crate) transactions: Vec<YnabTransaction>,
}