8 Commits

Author SHA1 Message Date
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
8 changed files with 159 additions and 20 deletions
Generated
+1 -1
View File
@@ -1963,7 +1963,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]] [[package]]
name = "ynabmunger" name = "ynabmunger"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+2 -1
View File
@@ -1,7 +1,8 @@
[package] [package]
name = "ynabmunger" name = "ynabmunger"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
publish = false
[dependencies] [dependencies]
anyhow = "1.0.102" anyhow = "1.0.102"
+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.
+3
View File
@@ -24,6 +24,9 @@ pub enum Error {
AmountEmpty, AmountEmpty,
#[error("bank format not found: {0}")] #[error("bank format not found: {0}")]
BankFormat(String), BankFormat(String),
#[error("API error {status}: {message}")]
ApiError { status: u16, message: String },
/// Errors I've been too lazy to give a type yet /// Errors I've been too lazy to give a type yet
#[error("{0}")] #[error("{0}")]
Text(String), Text(String),
+22 -7
View File
@@ -26,10 +26,11 @@ enum Command {
#[arg(short = 'P', long, group = "planref")] #[arg(short = 'P', long, group = "planref")]
plan_id: Option<String>, plan_id: Option<String>,
}, },
// List transactions in the last 30 days /// List transactions
// ///
// You have to give a plan to list transactions from. You can optionally /// You have to give a plan to list transactions from. You can optionally
// also give an account to show only transactions from that account. /// also give an account to show only transactions from that account. You can specify the
/// number of days, the default is 30.
Transactions { Transactions {
/// Your YNAB token, available from developer settings in YNAB /// Your YNAB token, available from developer settings in YNAB
#[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)] #[arg(short, long, env = "YNAB_API_TOKEN", hide_env_values = true)]
@@ -50,6 +51,15 @@ enum Command {
/// Alternatively, give the YNAB account ID directly /// Alternatively, give the YNAB account ID directly
#[arg(short = 'A', long, group = "accountref")] #[arg(short = 'A', long, group = "accountref")]
account_id: Option<String>, 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 {
@@ -135,6 +145,8 @@ async fn main() -> anyhow::Result<()> {
plan_id, plan_id,
account, account,
account_id, account_id,
days,
filter,
} => { } => {
let client = Client::new(&token); let client = Client::new(&token);
let plan = match (plan, plan_id) { let plan = match (plan, plan_id) {
@@ -151,12 +163,15 @@ async fn main() -> anyhow::Result<()> {
}; };
let transactions = match account { let transactions = match account {
Some(account) => account.list_transactions().await?, Some(account) => account.list_transactions(days, filter.as_deref()).await?,
None => plan.list_transactions(None).await?, None => {
plan.list_transactions(None, days, filter.as_deref())
.await?
}
}; };
println!("Transactions{} in the last 30 days:", accountstr); println!("Transactions{} in the last 30 days:", accountstr);
for t in transactions { for t in transactions {
println!("{},{},{}", t.date, t.payee, t.amount); println!("{},{},{}", t.date, t.payee, t.format_amount());
} }
Ok(()) Ok(())
+10 -8
View File
@@ -6,18 +6,20 @@ pub struct Transaction {
pub amount: i64, pub amount: i64,
} }
fn parse_amount_str(amount: &str) -> Result<i64, Error> { fn parse_amount_str(amount_str: &str) -> Result<i64, Error> {
if amount.is_empty() { if amount_str.is_empty() {
return Err(Error::AmountEmpty); return Err(Error::AmountEmpty);
} }
let (whole, frac) = amount.split_once('.').unwrap_or((amount, "00")); let negative = amount_str.starts_with("-");
let amount_str = amount_str.trim_start_matches('-');
let (whole, frac) = amount_str.split_once('.').unwrap_or((amount_str, "00"));
let whole = whole.parse::<i64>()?; let whole = whole.parse::<i64>()?;
let frac = format!("{:0<2}", frac);
let frac = frac.parse::<i64>()?; let frac = frac.parse::<i64>()?;
let mut amount: i64 = 0; let amount = whole * 100 * 10 + frac * 10;
amount += whole * 100 * 10; Ok(if negative { -amount } else { amount })
amount += if whole < 0 { -frac } else { frac } * 10;
Ok(amount)
} }
impl Transaction { impl Transaction {
@@ -30,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)
+20 -3
View File
@@ -26,6 +26,14 @@ impl Client {
.bearer_auth(&self.token) .bearer_auth(&self.token)
.send() .send()
.await?; .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?; let text = res.text().await?;
Ok(text) Ok(text)
} }
@@ -121,8 +129,10 @@ impl Plan {
pub async fn list_transactions( pub async fn list_transactions(
&self, &self,
account_id: Option<&str>, account_id: Option<&str>,
days: i64,
filter: Option<&str>,
) -> Result<Vec<Transaction>, Error> { ) -> Result<Vec<Transaction>, Error> {
let since_date = chrono::Local::now().date_naive() - chrono::Duration::days(30); let since_date = chrono::Local::now().date_naive() - chrono::Duration::days(days);
let since_date = since_date.format("%Y-%m-%d"); let since_date = since_date.format("%Y-%m-%d");
let url = format!("plans/{}/transactions?since_date={}", self.id, since_date); let url = format!("plans/{}/transactions?since_date={}", self.id, since_date);
let raw = self.client.get(&url).await?; let raw = self.client.get(&url).await?;
@@ -130,6 +140,7 @@ impl Plan {
Ok(transform::from_ynab_transactions( Ok(transform::from_ynab_transactions(
&res.data.transactions, &res.data.transactions,
account_id, account_id,
filter,
)) ))
} }
} }
@@ -166,8 +177,14 @@ impl Account {
}) })
} }
pub async fn list_transactions(&self) -> Result<Vec<Transaction>, Error> { pub async fn list_transactions(
self.plan.list_transactions(Some(&self.id)).await &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> { pub async fn upload(&self, transactions: &[Transaction]) -> Result<(), Error> {
+10
View File
@@ -31,6 +31,7 @@ pub(crate) fn to_ynab_transactions(
pub(crate) fn from_ynab_transactions( pub(crate) fn from_ynab_transactions(
transactions: &[apitypes::Transaction], transactions: &[apitypes::Transaction],
account_id: Option<&str>, account_id: Option<&str>,
filter: Option<&str>,
) -> Vec<Transaction> { ) -> Vec<Transaction> {
transactions transactions
.iter() .iter()
@@ -38,6 +39,15 @@ pub(crate) fn from_ynab_transactions(
Some(id) => t.account_id == id, Some(id) => t.account_id == id,
None => true, 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 { .map(|t| Transaction {
date: t.date.to_owned(), date: t.date.to_owned(),
payee: match &t.payee_name { payee: match &t.payee_name {