14 Commits

Author SHA1 Message Date
james ae543e5edf chore: Release ynabmunger version 0.3.0 2026-06-03 09:28:37 +02:00
james f7b645895d Format transaction amounts on output
This might want to use the same YNAB CSV format as `convert`.
2026-06-03 09:20:12 +02:00
james 14d0d82719 Tidy up amount calculation 2026-06-03 09:16:46 +02:00
james c9e005475c Add filter to transactions command 2026-06-03 09:16:05 +02:00
james e205280176 chore: Release ynabmunger version 0.2.0 2026-05-25 23:19:05 +02:00
james b8595bbd59 Disable cargo publishing 2026-05-25 23:18:48 +02:00
james 415a1f8684 Make number of days configurable for transactions 2026-05-25 23:15:20 +02:00
james da0d80f4f4 Fix amount parsing for negative amounts <1 2026-05-25 23:05:34 +02:00
james 08ffad118a Add README 2026-05-03 14:38:49 +02:00
james e678b43c15 Add goreleaser config 2026-05-03 14:24:04 +02:00
james fa4cc722e2 Change object model and add crate Error type 2026-05-03 14:17:50 +02:00
james a9de66a49f Switch to async reqwest and colour in functions all the way up 2026-05-03 10:38:28 +02:00
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
15 changed files with 714 additions and 283 deletions
+1
View File
@@ -1 +1,2 @@
/target /target
/dist
+15
View File
@@ -0,0 +1,15 @@
version: 2
before:
hooks:
- cargo install --locked cargo-zigbuild
builds:
- builder: rust
# targets:
# - x86_64-unknown-linux-gnu
# - aarch64-unknown-linux-gnu
archives:
- formats: ["binary"]
gitea_urls:
api: https://git.shee.sh/api/v1
download: https://git.shee.sh
Generated
+119 -1
View File
@@ -2,6 +2,15 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "anstream" name = "anstream"
version = "1.0.0" version = "1.0.0"
@@ -52,12 +61,24 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "aws-lc-rs" name = "aws-lc-rs"
version = "1.16.3" version = "1.16.3"
@@ -128,6 +149,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.6.1"
@@ -527,6 +561,30 @@ dependencies = [
"windows-registry", "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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.2.0" version = "2.2.0"
@@ -795,6 +853,15 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -1385,9 +1452,21 @@ dependencies = [
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"
@@ -1644,6 +1723,41 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@@ -1849,14 +1963,18 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "ynabmunger" name = "ynabmunger"
version = "0.1.0" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow",
"chrono",
"clap", "clap",
"csv", "csv",
"directories", "directories",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"thiserror",
"tokio",
] ]
[[package]] [[package]]
+6 -1
View File
@@ -1,12 +1,17 @@
[package] [package]
name = "ynabmunger" name = "ynabmunger"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
publish = false
[dependencies] [dependencies]
anyhow = "1.0.102"
chrono = "0.4.44"
clap = { version = "4.6.1", features = ["derive", "env"] } clap = { version = "4.6.1", features = ["derive", "env"] }
csv = "1.4.0" csv = "1.4.0"
directories = "6.0.0" directories = "6.0.0"
reqwest = { version = "0.13.3", features = ["blocking"] } reqwest = { version = "0.13.3", features = ["blocking"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
thiserror = "2.0.18"
tokio = { version = "1.52.1", features = ["macros", "rt-multi-thread"] }
+91
View File
@@ -0,0 +1,91 @@
# ynabmunger
A CLI tool for importing bank exports into YNAB.
## Installation
You can download binaries from the [Releases page](https://git.shee.sh/james/ynabmunger/releases).
To build locally, you need Rust installed and you can either:
```bash
cargo install --git https://git.shee.sh/james/ynabmunger
```
or clone the repo locally and run:
```bash
cargo install --path .
```
## Usage
All commands that interact with YNAB require your API token, available from [YNAB developer settings](https://app.ynab.com/settings/developer). Set it via the `--token` flag or the `YNAB_API_TOKEN` environment variable.
### Commands
#### `plans`
List available YNAB budgets:
```bash
ynabmunger plans --token YOUR_TOKEN
```
#### `accounts`
List accounts in a budget:
```bash
ynabmunger accounts --token YOUR_TOKEN --plan "My Budget"
```
You can reference a budget by name (`--plan`) or ID (`--plan-id`).
#### `transactions`
List recent transactions (last 30 days):
```bash
ynabmunger transactions --token YOUR_TOKEN --plan "My Budget"
```
Filter by account with `--account` or `--account-id`.
#### `convert`
Convert a bank export to YNAB-compatible CSV for manual import:
```bash
ynabmunger convert --format bulder bank_export.csv > ynab_import.csv
```
Reads from stdin if no input files are given:
```bash
cat bank_export.csv | ynabmunger convert --format bulder > ynab_import.csv
```
#### `import`
Import a bank export directly into a YNAB account:
```bash
ynabmunger import --token YOUR_TOKEN \
--plan "My Budget" \
--account "Checking" \
--format bulder \
bank_export.csv
```
## Bank Formats
Currently supported formats:
| Format | Description |
|--------|-------------|
| `bulder` | Bulder bank semicolon-delimited export |
### Adding New Formats
Add a new variant to the `BankFormat` enum in `src/csv.rs` and implement the reader and transform methods.
+1
View File
@@ -1,2 +1,3 @@
[tools] [tools]
rust = "latest" rust = "latest"
zig = "latest"
+28 -10
View File
@@ -1,9 +1,11 @@
use crate::Error;
use crate::Transaction; use crate::Transaction;
use csv::ReaderBuilder; use csv::ReaderBuilder;
use std::error::Error; use std::fs::File;
use std::io::{Read, Write}; use std::io::{Read, Write, stdin};
use std::path::PathBuf;
pub fn output_csv<W: Write>(output: W, transactions: &[Transaction]) -> Result<(), Box<dyn Error>> { pub fn output_csv<W: Write>(output: W, transactions: &[Transaction]) -> Result<(), Error> {
let mut writer = csv::Writer::from_writer(output); let mut writer = csv::Writer::from_writer(output);
writer writer
@@ -22,10 +24,10 @@ enum BankFormat {
} }
impl BankFormat { impl BankFormat {
fn from_str(format: &str) -> Result<BankFormat, String> { fn from_str(format: &str) -> Result<BankFormat, Error> {
match format { match format {
"bulder" => Ok(BankFormat::Bulder), "bulder" => Ok(BankFormat::Bulder),
_ => Err(format!("Bank '{}' not found", format)), _ => Err(Error::BankFormat(format.to_string())),
} }
} }
@@ -38,19 +40,35 @@ impl BankFormat {
} }
} }
fn transform(&self, record: &csv::StringRecord) -> Result<Transaction, String> { fn transform(&self, record: &csv::StringRecord) -> Result<Transaction, Error> {
match self { match self {
BankFormat::Bulder => Transaction::from_fields(&record[0], &record[9], &record[1]), 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> { pub fn read_transactions<R: Read>(input: R, format: &str) -> Result<Vec<Transaction>, Error> {
let format = BankFormat::from_str(format).unwrap(); let format = BankFormat::from_str(format).unwrap();
format format
.reader(input) .reader(input)
.records() .records()
.map(|r| r.map_err(|e| format!("parse error: {}", e))) .map(|r| format.transform(&r?))
.map(|r| r.and_then(|r| format.transform(&r))) .collect()
.collect::<Result<Vec<_>, _>>() }
pub fn read_transactions_from(inputs: &[PathBuf], format: &str) -> Result<Vec<Transaction>, Error> {
Ok(if inputs.is_empty() {
vec![Box::new(stdin()) as Box<dyn Read>]
} else {
inputs
.iter()
.map(|p| -> Result<Box<dyn Read>, Error> { Ok(Box::new(File::open(p)?)) })
.collect::<Result<Vec<_>, _>>()?
}
.iter_mut()
.map(|s| read_transactions(s, format))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect())
} }
+29
View File
@@ -2,3 +2,32 @@ mod transaction;
pub use transaction::Transaction; pub use transaction::Transaction;
pub mod csv; pub mod csv;
pub mod ynab; pub mod ynab;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("HTTP Error: {0}")]
Http(#[from] reqwest::Error),
#[error("parse error: {0}")]
Parse(#[from] serde_json::Error),
#[error("csv error: {0}")]
Csv(#[from] ::csv::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("couldn't parse amount: {0}")]
AmountParse(#[from] std::num::ParseIntError),
#[error("plan not found: {0}")]
PlanNotFound(String),
#[error("account not found: {0}")]
AccountNotFound(String),
#[error("empty amount")]
AmountEmpty,
#[error("bank format not found: {0}")]
BankFormat(String),
#[error("API error {status}: {message}")]
ApiError { status: u16, message: String },
/// Errors I've been too lazy to give a type yet
#[error("{0}")]
Text(String),
}
+101 -37
View File
@@ -1,14 +1,8 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::{ use std::{io::stdout, path::PathBuf};
error::Error,
fs::File,
io::{Read, stdin, stdout},
path::PathBuf,
};
use ynabmunger::Transaction; use ynabmunger::csv::{output_csv, read_transactions_from};
use ynabmunger::csv::{output_csv, read_transactions}; use ynabmunger::ynab::Client;
use ynabmunger::ynab::{Lookup, Ynab};
#[derive(Subcommand)] #[derive(Subcommand)]
enum Command { enum Command {
@@ -32,6 +26,41 @@ enum Command {
#[arg(short = 'P', long, group = "planref")] #[arg(short = 'P', long, group = "planref")]
plan_id: Option<String>, plan_id: Option<String>,
}, },
/// List transactions
///
/// You have to give a plan to list transactions from. You can optionally
/// also give an account to show only transactions from that account. You can specify the
/// number of days, the default is 30.
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>,
/// The number of days to look back for transactions
#[arg(short, long, default_value_t = 30)]
days: i64,
/// Filter transactions by a search term in the payee field. This will match any transaction
/// whose payee contains the search term, case-insensitive.
#[arg(short, long)]
filter: Option<String>,
},
/// Convert from a bank export to a CSV you can import manually to YNAB /// Convert from a bank export to a CSV you can import manually to YNAB
Convert { Convert {
#[arg(short, long, help = "Bank export format", default_value_t = String::from("bulder"))] #[arg(short, long, help = "Bank export format", default_value_t = String::from("bulder"))]
@@ -78,32 +107,13 @@ struct Cli {
command: Command, command: Command,
} }
fn read_transactions_from( #[tokio::main]
inputs: &[PathBuf], async fn main() -> anyhow::Result<()> {
format: &str,
) -> Result<Vec<Transaction>, Box<dyn Error>> {
Ok(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, format))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect())
}
fn main() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Command::Plans { token } => { Command::Plans { token } => {
let plans = Ynab::new(&token).list_plans()?; let plans = Client::new(&token).list_plans().await?;
println!("Available plans:"); println!("Available plans:");
for plan in plans { for plan in plans {
println!(" - {}", plan); println!(" - {}", plan);
@@ -115,14 +125,57 @@ fn main() -> Result<(), Box<dyn Error>> {
plan, plan,
plan_id, plan_id,
} => { } => {
let plan = Lookup::try_from((plan, plan_id))?; let client = Client::new(&token);
let accounts = Ynab::new(&token).list_accounts(plan)?; let plan = match (plan, plan_id) {
(_, Some(id)) => Ok(client.plan_from_id(&id)),
(Some(name), _) => Ok(client.plan_from_name(&name).await?),
_ => Err(ynabmunger::Error::Text("no plan given".to_string())),
}?;
let accounts = plan.list_accounts().await?;
println!("Available accounts in plan:"); println!("Available accounts in plan:");
for account in accounts { for account in accounts {
println!(" - {}", account); println!(" - {}", account);
} }
Ok(()) Ok(())
} }
Command::Transactions {
token,
plan,
plan_id,
account,
account_id,
days,
filter,
} => {
let client = Client::new(&token);
let plan = match (plan, plan_id) {
(_, Some(id)) => Ok(client.plan_from_id(&id)),
(Some(name), _) => Ok(client.plan_from_name(&name).await?),
_ => Err(ynabmunger::Error::Text("no plan given".to_string())),
}?;
// Account is optional here
let (account, accountstr) = match (account, account_id) {
(_, Some(id)) => (Some(plan.account_from_id(&id)), " for account"),
(Some(name), _) => (Some(plan.account_from_name(&name).await?), " for account"),
_ => (None, ""),
};
let transactions = match account {
Some(account) => account.list_transactions(days, filter.as_deref()).await?,
None => {
plan.list_transactions(None, days, filter.as_deref())
.await?
}
};
println!("Transactions{} in the last 30 days:", accountstr);
for t in transactions {
println!("{},{},{}", t.date, t.payee, t.format_amount());
}
Ok(())
}
Command::Import { Command::Import {
token, token,
plan, plan,
@@ -132,16 +185,27 @@ fn main() -> Result<(), Box<dyn Error>> {
format, format,
inputs, inputs,
} => { } => {
let plan = Lookup::try_from((plan, plan_id))?; let client = Client::new(&token);
let account = Lookup::try_from((account, account_id))?; let plan = match (plan, plan_id) {
(_, Some(id)) => Ok(client.plan_from_id(&id)),
(Some(name), _) => Ok(client.plan_from_name(&name).await?),
_ => Err(ynabmunger::Error::Text("no plan given".to_string())),
}?;
let account = match (account, account_id) {
(_, Some(id)) => Ok(plan.account_from_id(&id)),
(Some(name), _) => Ok(plan.account_from_name(&name).await?),
_ => Err(ynabmunger::Error::Text("no account given".to_string())),
}?;
let transactions = read_transactions_from(&inputs, &format)?; let transactions = read_transactions_from(&inputs, &format)?;
Ynab::new(&token).upload(&transactions, plan, account) account.upload(&transactions).await?;
Ok(())
} }
Command::Convert { format, inputs } => { Command::Convert { format, inputs } => {
let transactions = read_transactions_from(&inputs, &format)?; let transactions = read_transactions_from(&inputs, &format)?;
output_csv(stdout(), &transactions) output_csv(stdout(), &transactions)?;
Ok(())
} }
} }
} }
+16 -16
View File
@@ -1,30 +1,30 @@
use crate::Error;
pub struct Transaction { pub struct Transaction {
pub date: String, pub date: String,
pub payee: String, pub payee: String,
pub amount: i64, pub amount: i64,
} }
fn parse_amount_str(amount: &str) -> Result<i64, String> { fn parse_amount_str(amount_str: &str) -> Result<i64, Error> {
if amount.is_empty() { if amount_str.is_empty() {
return Err("empty amount".to_string()); return Err(Error::AmountEmpty);
} }
let (whole, frac) = amount.split_once('.').unwrap_or((amount, "00")); let negative = amount_str.starts_with("-");
let whole = whole let amount_str = amount_str.trim_start_matches('-');
.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; let (whole, frac) = amount_str.split_once('.').unwrap_or((amount_str, "00"));
amount += whole * 100 * 10; let whole = whole.parse::<i64>()?;
amount += if whole < 0 { -frac } else { frac } * 10; let frac = format!("{:0<2}", frac);
Ok(amount) let frac = frac.parse::<i64>()?;
let amount = whole * 100 * 10 + frac * 10;
Ok(if negative { -amount } else { amount })
} }
impl Transaction { impl Transaction {
/// Construct a Transaction from [date, payee, amount] strings /// Construct a Transaction from [date, payee, amount] strings
pub fn from_fields(date: &str, payee: &str, amount: &str) -> Result<Transaction, String> { pub fn from_fields(date: &str, payee: &str, amount: &str) -> Result<Transaction, Error> {
Ok(Transaction { Ok(Transaction {
date: date.to_owned(), date: date.to_owned(),
payee: payee.to_owned(), payee: payee.to_owned(),
@@ -32,7 +32,7 @@ impl Transaction {
}) })
} }
fn format_amount(&self) -> String { pub fn format_amount(&self) -> String {
let whole = self.amount / 10 / 100; let whole = self.amount / 10 / 100;
let frac = if self.amount < 0 { -1 } else { 1 } * (self.amount / 10) % 100; let frac = if self.amount < 0 { -1 } else { 1 } * (self.amount / 10) % 100;
format!("{}.{:02}", whole, frac) format!("{}.{:02}", whole, frac)
-218
View File
@@ -1,218 +0,0 @@
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,
webclient: reqwest::blocking::Client,
}
impl Ynab {
pub fn new(token: &str) -> Ynab {
Ynab {
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))?
))
}
}
pub fn list_plans(&self) -> Result<Vec<String>, String> {
let url = String::from("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))?;
Ok(res.data.plans.into_iter().map(|p| p.name).collect())
}
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(
&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())
}
fn ynab_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
}
pub fn upload(
&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 {
transactions: self.ynab_transactions(transactions, &account_id),
})
.map_err(|e| format!("transaction format error: {}", e))?;
println!("{}", body);
self.post(&request, &body)?;
Ok(())
}
}
+43
View File
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub(crate) struct Response<T> {
pub(crate) data: T,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct TransactionList {
pub(crate) transactions: Vec<Transaction>,
}
#[derive(Serialize, Deserialize)]
pub(crate) struct Transaction {
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(Deserialize)]
pub(crate) struct PlanList {
pub(crate) plans: Vec<Plan>,
}
#[derive(Deserialize)]
pub(crate) struct Plan {
pub(crate) id: String,
pub(crate) name: String,
}
#[derive(Deserialize)]
pub(crate) struct AccountList {
pub(crate) accounts: Vec<Account>,
}
#[derive(Deserialize)]
pub(crate) struct Account {
pub(crate) id: String,
pub(crate) name: String,
}
+198
View File
@@ -0,0 +1,198 @@
use super::apitypes;
use super::transform;
use crate::Error;
use crate::Transaction;
#[derive(Clone)]
pub struct Client {
token: String,
webclient: reqwest::Client,
}
impl Client {
pub fn new(token: &str) -> Client {
Client {
token: token.to_owned(),
webclient: reqwest::Client::new(),
}
}
pub(crate) async fn get(&self, request: &str) -> Result<String, 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()
.await?;
if !res.status().is_success() {
let code = res.status().as_u16();
let message = res.status().canonical_reason().unwrap_or("unknown");
return Err(Error::ApiError {
status: code,
message: message.to_string(),
});
}
let text = res.text().await?;
Ok(text)
}
async fn post(&self, request: &str, body: &str) -> Result<(), Error> {
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()
.await?;
if res.status().is_success() {
// Maybe do something with the status here
// println!(
// "successful api response {}: {}",
// res.status(),
// res.text()
// .await
// .map_err(|e| format!("error retrieving api error: {}", e))?
// );
Ok(())
} else {
Err(Error::Text(format!(
"api error: {}: {}",
res.status(),
res.text().await?
)))
}
}
pub async fn list_plans(&self) -> Result<Vec<String>, Error> {
let url = String::from("plans");
let res: apitypes::Response<apitypes::PlanList> =
serde_json::from_str(&self.get(&url).await?)?;
Ok(res.data.plans.into_iter().map(|p| p.name).collect())
}
pub fn plan_from_id(self, id: &str) -> Plan {
Plan::from_id(self, id)
}
pub async fn plan_from_name(self, name: &str) -> Result<Plan, Error> {
Plan::from_name(self, name).await
}
}
#[derive(Clone)]
pub struct Plan {
client: Client,
id: String,
}
impl Plan {
fn from_id(client: Client, id: &str) -> Plan {
Plan {
client,
id: id.to_owned(),
}
}
async fn from_name(client: Client, name: &str) -> Result<Plan, Error> {
let raw = client.get("plans").await?;
let res: apitypes::Response<apitypes::PlanList> = serde_json::from_str(&raw)?;
let id = res
.data
.plans
.into_iter()
.find(|p| p.name == name)
.map(|p| p.id)
.ok_or_else(|| Error::PlanNotFound(name.to_string()))?;
Ok(Plan { client, id })
}
pub fn account_from_id(&self, id: &str) -> Account {
Account::from_id(self, id)
}
pub async fn account_from_name(&self, name: &str) -> Result<Account, Error> {
Account::from_name(self, name).await
}
pub async fn list_accounts(&self) -> Result<Vec<String>, Error> {
let url = format!("plans/{}/accounts", self.id);
let raw = self.client.get(&url).await?;
let res: apitypes::Response<apitypes::AccountList> = serde_json::from_str(&raw)?;
Ok(res.data.accounts.into_iter().map(|p| p.name).collect())
}
pub async fn list_transactions(
&self,
account_id: Option<&str>,
days: i64,
filter: Option<&str>,
) -> Result<Vec<Transaction>, Error> {
let since_date = chrono::Local::now().date_naive() - chrono::Duration::days(days);
let since_date = since_date.format("%Y-%m-%d");
let url = format!("plans/{}/transactions?since_date={}", self.id, since_date);
let raw = self.client.get(&url).await?;
let res: apitypes::Response<apitypes::TransactionList> = serde_json::from_str(&raw)?;
Ok(transform::from_ynab_transactions(
&res.data.transactions,
account_id,
filter,
))
}
}
pub struct Account {
plan: Plan,
id: String,
}
impl Account {
fn from_id(plan: &Plan, id: &str) -> Account {
Account {
plan: plan.clone(),
id: id.to_owned(),
}
}
async fn from_name(plan: &Plan, name: &str) -> Result<Account, Error> {
let raw = plan
.client
.get(&format!("plans/{}/accounts", &plan.id))
.await?;
let res: apitypes::Response<apitypes::AccountList> = serde_json::from_str(&raw)?;
let id = res
.data
.accounts
.into_iter()
.find(|p| p.name == name)
.map(|p| p.id)
.ok_or_else(|| Error::AccountNotFound(name.to_string()))?;
Ok(Account {
plan: plan.clone(),
id,
})
}
pub async fn list_transactions(
&self,
days: i64,
filter: Option<&str>,
) -> Result<Vec<Transaction>, Error> {
self.plan
.list_transactions(Some(&self.id), days, filter)
.await
}
pub async fn upload(&self, transactions: &[Transaction]) -> Result<(), Error> {
let request = format!("plans/{}/transactions", self.plan.id);
let body = serde_json::to_string(&apitypes::TransactionList {
transactions: transform::to_ynab_transactions(transactions, &self.id),
})?;
self.plan.client.post(&request, &body).await?;
Ok(())
}
}
+6
View File
@@ -0,0 +1,6 @@
mod apitypes;
mod client;
pub use client::Client;
mod transform;
+60
View File
@@ -0,0 +1,60 @@
use super::apitypes;
use crate::Transaction;
use std::collections::HashMap;
pub(crate) fn to_ynab_transactions(
transactions: &[Transaction],
account_id: &str,
) -> Vec<apitypes::Transaction> {
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 = apitypes::Transaction {
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: &[apitypes::Transaction],
account_id: Option<&str>,
filter: Option<&str>,
) -> Vec<Transaction> {
transactions
.iter()
.filter(|t| match account_id {
Some(id) => t.account_id == id,
None => true,
})
.filter(|t| match filter {
Some(filter) => t
.payee_name
.to_owned()
.unwrap_or(String::new())
.to_lowercase()
.contains(filter.to_lowercase().as_str()),
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()
}