Compare commits
3 Commits
deda785c2a
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| e678b43c15 | |||
| fa4cc722e2 | |||
| a9de66a49f |
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
/dist
|
||||||
|
|||||||
@@ -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
+21
@@ -61,6 +61,12 @@ 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"
|
||||||
@@ -1446,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"
|
||||||
@@ -1947,6 +1965,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
|||||||
name = "ynabmunger"
|
name = "ynabmunger"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"csv",
|
"csv",
|
||||||
@@ -1954,6 +1973,8 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = "1.0.102"
|
||||||
chrono = "0.4.44"
|
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"
|
||||||
@@ -11,3 +12,5 @@ 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"] }
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
[tools]
|
[tools]
|
||||||
rust = "latest"
|
rust = "latest"
|
||||||
|
zig = "latest"
|
||||||
|
|||||||
+28
-10
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -2,3 +2,29 @@ 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),
|
||||||
|
/// Errors I've been too lazy to give a type yet
|
||||||
|
#[error("{0}")]
|
||||||
|
Text(String),
|
||||||
|
}
|
||||||
|
|||||||
+44
-49
@@ -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 {
|
||||||
@@ -103,32 +97,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);
|
||||||
@@ -140,8 +115,14 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
plan,
|
plan,
|
||||||
plan_id,
|
plan_id,
|
||||||
} => {
|
} => {
|
||||||
let plan = Lookup::from_options(plan, plan_id)?;
|
let client = Client::new(&token);
|
||||||
let accounts = Ynab::list_accounts(&Ynab::new(&token), 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);
|
||||||
@@ -155,21 +136,24 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
account,
|
account,
|
||||||
account_id,
|
account_id,
|
||||||
} => {
|
} => {
|
||||||
let plan = Lookup::from_options(plan, plan_id)?;
|
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
|
// Account is optional here
|
||||||
let accountstr: &str;
|
let (account, accountstr) = match (account, account_id) {
|
||||||
let account = match (account, account_id) {
|
(_, Some(id)) => (Some(plan.account_from_id(&id)), " for account"),
|
||||||
(None, None) => {
|
(Some(name), _) => (Some(plan.account_from_name(&name).await?), " for account"),
|
||||||
accountstr = "";
|
_ => (None, ""),
|
||||||
None
|
};
|
||||||
}
|
|
||||||
(name, id) => {
|
let transactions = match account {
|
||||||
accountstr = " for account";
|
Some(account) => account.list_transactions().await?,
|
||||||
Some(Lookup::from_options(name, id)?)
|
None => plan.list_transactions(None).await?,
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let transactions = Ynab::new(&token).list_transactions(plan, account)?;
|
|
||||||
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.amount);
|
||||||
@@ -186,16 +170,27 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
format,
|
format,
|
||||||
inputs,
|
inputs,
|
||||||
} => {
|
} => {
|
||||||
let plan = Lookup::from_options(plan, plan_id)?;
|
let client = Client::new(&token);
|
||||||
let account = Lookup::from_options(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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-9
@@ -1,20 +1,18 @@
|
|||||||
|
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) -> Result<i64, Error> {
|
||||||
if amount.is_empty() {
|
if amount.is_empty() {
|
||||||
return Err("empty amount".to_string());
|
return Err(Error::AmountEmpty);
|
||||||
}
|
}
|
||||||
let (whole, frac) = amount.split_once('.').unwrap_or((amount, "00"));
|
let (whole, frac) = amount.split_once('.').unwrap_or((amount, "00"));
|
||||||
let whole = whole
|
let whole = whole.parse::<i64>()?;
|
||||||
.parse::<i64>()
|
let frac = frac.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 mut amount: i64 = 0;
|
||||||
amount += whole * 100 * 10;
|
amount += whole * 100 * 10;
|
||||||
@@ -24,7 +22,7 @@ fn parse_amount_str(amount: &str) -> Result<i64, String> {
|
|||||||
|
|
||||||
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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
+126
-106
@@ -1,74 +1,36 @@
|
|||||||
use super::lookup::Lookup;
|
use super::apitypes;
|
||||||
use super::transform;
|
use super::transform;
|
||||||
use super::types;
|
use crate::Error;
|
||||||
use crate::Transaction;
|
use crate::Transaction;
|
||||||
|
|
||||||
pub struct Ynab {
|
#[derive(Clone)]
|
||||||
|
pub struct Client {
|
||||||
token: String,
|
token: String,
|
||||||
webclient: reqwest::blocking::Client,
|
webclient: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ynab {
|
impl Client {
|
||||||
pub fn new(token: &str) -> Ynab {
|
pub fn new(token: &str) -> Client {
|
||||||
Ynab {
|
Client {
|
||||||
token: token.to_owned(),
|
token: token.to_owned(),
|
||||||
webclient: reqwest::blocking::Client::new(),
|
webclient: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_plan(&self, plan: Lookup) -> Result<String, String> {
|
pub(crate) async fn get(&self, request: &str) -> Result<String, Error> {
|
||||||
match plan {
|
|
||||||
Lookup::Id(id) => Ok(id),
|
|
||||||
Lookup::Name(name) => {
|
|
||||||
let url = "plans";
|
|
||||||
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))?;
|
|
||||||
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: types::YnabResponse<types::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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn get(&self, request: &str) -> Result<String, reqwest::Error> {
|
|
||||||
const URL: &str = "https://api.ynab.com/v1/";
|
const URL: &str = "https://api.ynab.com/v1/";
|
||||||
let request = format!("{}{}", URL, request);
|
let request = format!("{}{}", URL, request);
|
||||||
let res = self
|
let res = self
|
||||||
.webclient
|
.webclient
|
||||||
.get(request)
|
.get(request)
|
||||||
.bearer_auth(&self.token)
|
.bearer_auth(&self.token)
|
||||||
.send()?;
|
.send()
|
||||||
let text = res.text()?;
|
.await?;
|
||||||
|
let text = res.text().await?;
|
||||||
Ok(text)
|
Ok(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn post(&self, request: &str, body: &str) -> Result<(), String> {
|
async fn post(&self, request: &str, body: &str) -> Result<(), Error> {
|
||||||
const URL: &str = "https://api.ynab.com/v1/";
|
const URL: &str = "https://api.ynab.com/v1/";
|
||||||
let request = format!("{}{}", URL, request);
|
let request = format!("{}{}", URL, request);
|
||||||
let res = self
|
let res = self
|
||||||
@@ -78,84 +40,142 @@ impl Ynab {
|
|||||||
.bearer_auth(&self.token)
|
.bearer_auth(&self.token)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.send()
|
.send()
|
||||||
.map_err(|e| format!("post error: {}", e))?;
|
.await?;
|
||||||
if res.status().is_success() {
|
if res.status().is_success() {
|
||||||
println!(
|
// Maybe do something with the status here
|
||||||
"successful api response {}: {}",
|
// println!(
|
||||||
res.status(),
|
// "successful api response {}: {}",
|
||||||
res.text()
|
// res.status(),
|
||||||
.map_err(|e| format!("error retrieving api error: {}", e))?
|
// res.text()
|
||||||
);
|
// .await
|
||||||
|
// .map_err(|e| format!("error retrieving api error: {}", e))?
|
||||||
|
// );
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(format!(
|
Err(Error::Text(format!(
|
||||||
"api error: {}: {}",
|
"api error: {}: {}",
|
||||||
res.status(),
|
res.status(),
|
||||||
res.text()
|
res.text().await?
|
||||||
.map_err(|e| format!("error retrieving api error: {}", e))?
|
)))
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_plans(&self) -> Result<Vec<String>, String> {
|
pub async fn list_plans(&self) -> Result<Vec<String>, Error> {
|
||||||
let url = String::from("plans");
|
let url = String::from("plans");
|
||||||
let res: types::YnabResponse<types::YnabPlanList> = serde_json::from_str(
|
let res: apitypes::Response<apitypes::PlanList> =
|
||||||
&self
|
serde_json::from_str(&self.get(&url).await?)?;
|
||||||
.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())
|
Ok(res.data.plans.into_iter().map(|p| p.name).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_accounts(&self, plan: Lookup) -> Result<Vec<String>, String> {
|
pub fn plan_from_id(self, id: &str) -> Plan {
|
||||||
let plan_id = self.resolve_plan(plan)?;
|
Plan::from_id(self, id)
|
||||||
let url = format!("plans/{}/accounts", plan_id);
|
}
|
||||||
let res: types::YnabResponse<types::YnabAccountList> = serde_json::from_str(
|
|
||||||
&self
|
pub async fn plan_from_name(self, name: &str) -> Result<Plan, Error> {
|
||||||
.get(&url)
|
Plan::from_name(self, name).await
|
||||||
.map_err(|e| format!("request error: {}", e))?,
|
}
|
||||||
)
|
}
|
||||||
.map_err(|e| format!("parse error: {}", e))?;
|
|
||||||
|
#[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())
|
Ok(res.data.accounts.into_iter().map(|p| p.name).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_transactions(
|
pub async fn list_transactions(
|
||||||
&self,
|
&self,
|
||||||
plan: Lookup,
|
account_id: Option<&str>,
|
||||||
account: Option<Lookup>,
|
) -> Result<Vec<Transaction>, Error> {
|
||||||
) -> 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 = chrono::Local::now().date_naive() - chrono::Duration::days(30);
|
||||||
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={}", plan_id, since_date);
|
let url = format!("plans/{}/transactions?since_date={}", self.id, since_date);
|
||||||
let raw = self.get(&url).map_err(|e| format!("request error {}", e))?;
|
let raw = self.client.get(&url).await?;
|
||||||
let res: types::YnabResponse<types::YnabTransactionList> =
|
let res: apitypes::Response<apitypes::TransactionList> = serde_json::from_str(&raw)?;
|
||||||
serde_json::from_str(&raw).map_err(|e| format!("parse error: {}", e))?;
|
|
||||||
Ok(transform::from_ynab_transactions(
|
Ok(transform::from_ynab_transactions(
|
||||||
&res.data.transactions,
|
&res.data.transactions,
|
||||||
account_id.as_deref(),
|
account_id,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn upload(
|
pub struct Account {
|
||||||
&self,
|
plan: Plan,
|
||||||
transactions: &[Transaction],
|
id: String,
|
||||||
plan: Lookup,
|
}
|
||||||
account: Lookup,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
impl Account {
|
||||||
let plan_id = self.resolve_plan(plan)?;
|
fn from_id(plan: &Plan, id: &str) -> Account {
|
||||||
let account_id = self.resolve_account(&plan_id, account)?;
|
Account {
|
||||||
let request = format!("plans/{}/transactions", plan_id);
|
plan: plan.clone(),
|
||||||
let body = serde_json::to_string(&types::YnabTransactionList {
|
id: id.to_owned(),
|
||||||
transactions: transform::to_ynab_transactions(transactions, &account_id),
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("transaction format error: {}", e))?;
|
}
|
||||||
self.post(&request, &body)?;
|
|
||||||
|
pub async fn list_transactions(&self) -> Result<Vec<Transaction>, Error> {
|
||||||
|
self.plan.list_transactions(Some(&self.id)).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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-5
@@ -1,9 +1,6 @@
|
|||||||
mod lookup;
|
mod apitypes;
|
||||||
pub use lookup::Lookup;
|
|
||||||
|
|
||||||
mod types;
|
|
||||||
|
|
||||||
mod client;
|
mod client;
|
||||||
pub use client::Ynab;
|
pub use client::Client;
|
||||||
|
|
||||||
mod transform;
|
mod transform;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use super::types;
|
use super::apitypes;
|
||||||
use crate::Transaction;
|
use crate::Transaction;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub(crate) fn to_ynab_transactions(
|
pub(crate) fn to_ynab_transactions(
|
||||||
transactions: &[Transaction],
|
transactions: &[Transaction],
|
||||||
account_id: &str,
|
account_id: &str,
|
||||||
) -> Vec<types::YnabTransaction> {
|
) -> Vec<apitypes::Transaction> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut idmap: HashMap<(String, i64), u32> = HashMap::new();
|
let mut idmap: HashMap<(String, i64), u32> = HashMap::new();
|
||||||
for t in transactions {
|
for t in transactions {
|
||||||
@@ -15,7 +15,7 @@ pub(crate) fn to_ynab_transactions(
|
|||||||
*n += 1;
|
*n += 1;
|
||||||
*n
|
*n
|
||||||
};
|
};
|
||||||
let y = types::YnabTransaction {
|
let y = apitypes::Transaction {
|
||||||
import_id: Some(format!("YNAB:{}:{}:{}", t.amount, t.date, id)),
|
import_id: Some(format!("YNAB:{}:{}:{}", t.amount, t.date, id)),
|
||||||
date: t.date.clone(),
|
date: t.date.clone(),
|
||||||
amount: t.amount,
|
amount: t.amount,
|
||||||
@@ -29,7 +29,7 @@ pub(crate) fn to_ynab_transactions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn from_ynab_transactions(
|
pub(crate) fn from_ynab_transactions(
|
||||||
transactions: &[types::YnabTransaction],
|
transactions: &[apitypes::Transaction],
|
||||||
account_id: Option<&str>,
|
account_id: Option<&str>,
|
||||||
) -> Vec<Transaction> {
|
) -> Vec<Transaction> {
|
||||||
transactions
|
transactions
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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>,
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user