Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e205280176 | |||
| b8595bbd59 | |||
| 415a1f8684 | |||
| da0d80f4f4 | |||
| 08ffad118a |
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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),
|
||||||
|
|||||||
+12
-6
@@ -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,10 @@ 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,
|
||||||
},
|
},
|
||||||
/// 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 +140,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
plan_id,
|
plan_id,
|
||||||
account,
|
account,
|
||||||
account_id,
|
account_id,
|
||||||
|
days,
|
||||||
} => {
|
} => {
|
||||||
let client = Client::new(&token);
|
let client = Client::new(&token);
|
||||||
let plan = match (plan, plan_id) {
|
let plan = match (plan, plan_id) {
|
||||||
@@ -151,8 +157,8 @@ 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).await?,
|
||||||
None => plan.list_transactions(None).await?,
|
None => plan.list_transactions(None, days).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 {
|
||||||
|
|||||||
+9
-5
@@ -6,18 +6,22 @@ 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 mut amount: i64 = 0;
|
||||||
amount += whole * 100 * 10;
|
amount += whole * 100 * 10;
|
||||||
amount += if whole < 0 { -frac } else { frac } * 10;
|
amount += frac * 10;
|
||||||
Ok(amount)
|
Ok(if negative { -amount } else { amount })
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Transaction {
|
impl Transaction {
|
||||||
|
|||||||
+12
-3
@@ -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,9 @@ impl Plan {
|
|||||||
pub async fn list_transactions(
|
pub async fn list_transactions(
|
||||||
&self,
|
&self,
|
||||||
account_id: Option<&str>,
|
account_id: Option<&str>,
|
||||||
|
days: i64,
|
||||||
) -> 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?;
|
||||||
@@ -166,8 +175,8 @@ impl Account {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_transactions(&self) -> Result<Vec<Transaction>, Error> {
|
pub async fn list_transactions(&self, days: i64) -> Result<Vec<Transaction>, Error> {
|
||||||
self.plan.list_transactions(Some(&self.id)).await
|
self.plan.list_transactions(Some(&self.id), days).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn upload(&self, transactions: &[Transaction]) -> Result<(), Error> {
|
pub async fn upload(&self, transactions: &[Transaction]) -> Result<(), Error> {
|
||||||
|
|||||||
Reference in New Issue
Block a user