diff --git a/Cargo.lock b/Cargo.lock index 9c12e4a..6a7e62e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index b8f2f0c..9a35cfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/main.rs b/src/main.rs index 462026e..55ea2e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,31 @@ enum Command { #[arg(short = 'P', long, group = "planref")] plan_id: Option, }, + // 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, + + /// Alternatively, give the YNAB plan ID directly + #[arg(short = 'P', long, group = "planref")] + plan_id: Option, + + /// The name of the YNAB account to import to + #[arg(short, long, group = "accountref")] + account: Option, + + /// Alternatively, give the YNAB account ID directly + #[arg(short = 'A', long, group = "accountref")] + account_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"))] @@ -116,13 +141,42 @@ fn main() -> Result<(), Box> { plan_id, } => { let plan = Lookup::from_options(plan, plan_id)?; - let accounts = Ynab::new(&token).list_accounts(plan)?; + 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, diff --git a/src/ynab/client.rs b/src/ynab/client.rs index 556fbf9..491e2a0 100644 --- a/src/ynab/client.rs +++ b/src/ynab/client.rs @@ -80,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!( @@ -114,6 +120,28 @@ impl Ynab { Ok(res.data.accounts.into_iter().map(|p| p.name).collect()) } + pub fn list_transactions( + &self, + plan: Lookup, + account: Option, + ) -> Result, 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 = + 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( &self, transactions: &[Transaction], @@ -124,10 +152,9 @@ impl Ynab { let account_id = self.resolve_account(&plan_id, account)?; let request = format!("plans/{}/transactions", plan_id); let body = serde_json::to_string(&types::YnabTransactionList { - transactions: transform::ynab_transactions(transactions, &account_id), + transactions: transform::to_ynab_transactions(transactions, &account_id), }) .map_err(|e| format!("transaction format error: {}", e))?; - println!("{}", body); self.post(&request, &body)?; Ok(()) } diff --git a/src/ynab/transform.rs b/src/ynab/transform.rs index ae03b5f..f814e45 100644 --- a/src/ynab/transform.rs +++ b/src/ynab/transform.rs @@ -2,7 +2,7 @@ use super::types; use crate::Transaction; use std::collections::HashMap; -pub(crate) fn ynab_transactions( +pub(crate) fn to_ynab_transactions( transactions: &[Transaction], account_id: &str, ) -> Vec { @@ -16,13 +16,35 @@ pub(crate) fn ynab_transactions( *n }; let y = types::YnabTransaction { - import_id: format!("YNAB:{}:{}:{}", t.amount, t.date, id), + import_id: Some(format!("YNAB:{}:{}:{}", t.amount, t.date, id)), date: t.date.clone(), amount: t.amount, - payee_name: t.payee.clone(), + 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 { + 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() +} diff --git a/src/ynab/types.rs b/src/ynab/types.rs index 36c92f5..f6bd4c6 100644 --- a/src/ynab/types.rs +++ b/src/ynab/types.rs @@ -1,12 +1,13 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize)] +#[derive(Deserialize, Debug, Serialize)] pub(crate) struct YnabTransaction { - pub(crate) import_id: String, + pub(crate) import_id: Option, pub(crate) date: String, pub(crate) amount: i64, - pub(crate) payee_name: String, + pub(crate) payee_name: Option, pub(crate) account_id: String, + pub(crate) cleared: String, } #[derive(Serialize, Deserialize, Debug)] @@ -36,7 +37,7 @@ pub(crate) struct YnabAccount { pub(crate) name: String, } -#[derive(Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] pub(crate) struct YnabTransactionList { pub(crate) transactions: Vec, }