Add ability to submit transactions to YNAB

This commit is contained in:
Nick Krichevsky 2022-10-09 14:21:06 -04:00
parent c739436c3a
commit 67500348b0
8 changed files with 546 additions and 69 deletions

297
Cargo.lock generated
View file

@ -467,6 +467,12 @@ dependencies = [
"log", "log",
] ]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.3.2" version = "0.3.2"
@ -484,11 +490,10 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.0.1" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8"
dependencies = [ dependencies = [
"matches",
"percent-encoding", "percent-encoding",
] ]
@ -664,6 +669,25 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "h2"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca32592cf21ac7ccab1825cd87f6c9b3d9022c44d086172ed0966bec8af30be"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.11.2" version = "0.11.2"
@ -693,6 +717,77 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "http"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399"
dependencies = [
"bytes",
"fnv",
"itoa 1.0.1",
]
[[package]]
name = "http-body"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
dependencies = [
"bytes",
"http",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
[[package]]
name = "httpdate"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
[[package]]
name = "hyper"
version = "0.14.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02c929dc5c39e335a03c405292728118860721b10190d98c2a0f0efd5baafbac"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa 1.0.1",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.50" version = "0.1.50"
@ -708,11 +803,10 @@ dependencies = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "0.2.3" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
dependencies = [ dependencies = [
"matches",
"unicode-bidi", "unicode-bidi",
"unicode-normalization", "unicode-normalization",
] ]
@ -745,6 +839,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "ipnet"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.10.4" version = "0.10.4"
@ -865,6 +965,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "mime"
version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@ -873,25 +979,14 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "0.8.2" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52da4364ffb0e4fe33a9841a98a3f3014fb964045ce4f7a45a398243c8d6b0c9" checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"miow",
"ntapi",
"wasi 0.11.0+wasi-snapshot-preview1", "wasi 0.11.0+wasi-snapshot-preview1",
"winapi", "windows-sys",
]
[[package]]
name = "miow"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21"
dependencies = [
"winapi",
] ]
[[package]] [[package]]
@ -934,15 +1029,6 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "ntapi"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28774a7fd2fbb4f0babd8237ce554b73af68021b5f695a3cebd6c59bac0980f"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.45" version = "0.1.45"
@ -1086,9 +1172,9 @@ dependencies = [
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.1.0" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e"
[[package]] [[package]]
name = "phf" name = "phf"
@ -1396,6 +1482,43 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "reqwest"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "431949c384f4e2ae07605ccaa56d1d9d2ecdb5cadd4f9577ccfab29f2e5149fc"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"hyper",
"hyper-tls",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"serde_urlencoded",
"tokio",
"tokio-native-tls",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.0" version = "0.4.0"
@ -1512,6 +1635,29 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_json"
version = "1.0.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44"
dependencies = [
"itoa 1.0.1",
"ryu",
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa 1.0.1",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_yaml" name = "serde_yaml"
version = "0.8.24" version = "0.8.24"
@ -1782,16 +1928,16 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.18.1" version = "1.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce653fb475565de9f6fb0614b28bca8df2c430c0cf84bcd9c843f15de5414cc" checksum = "a9e03c497dc955702ba729190dc4aac6f2a0ce97f913e5b1b5912fc5039d9099"
dependencies = [ dependencies = [
"autocfg",
"bytes", "bytes",
"libc", "libc",
"memchr", "memchr",
"mio", "mio",
"num_cpus", "num_cpus",
"once_cell",
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
@ -1811,6 +1957,62 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-native-tls"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb2e075f03b3d66d8d8785356224ba688d2906a371015e225beeb65ca92c740"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
"tracing",
]
[[package]]
name = "tower-service"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
[[package]]
name = "tracing"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09"
dependencies = [
"cfg-if",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f"
dependencies = [
"lazy_static",
]
[[package]]
name = "try-lock"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.8" version = "0.3.8"
@ -1849,13 +2051,12 @@ checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04"
[[package]] [[package]]
name = "url" name = "url"
version = "2.2.2" version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna",
"matches",
"percent-encoding", "percent-encoding",
] ]
@ -1893,6 +2094,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca"
[[package]]
name = "want"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0"
dependencies = [
"log",
"try-lock",
]
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.9.0+wasi-snapshot-preview1" version = "0.9.0+wasi-snapshot-preview1"
@ -2070,6 +2281,15 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680"
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "yaml-rust" name = "yaml-rust"
version = "0.4.5" version = "0.4.5"
@ -2096,8 +2316,10 @@ dependencies = [
"log", "log",
"mailparse", "mailparse",
"regex", "regex",
"reqwest",
"scraper", "scraper",
"serde", "serde",
"serde_json",
"serde_yaml", "serde_yaml",
"simplelog", "simplelog",
"stop-token", "stop-token",
@ -2105,4 +2327,5 @@ dependencies = [
"textwrap", "textwrap",
"thiserror", "thiserror",
"tokio", "tokio",
"url",
] ]

View file

@ -8,6 +8,7 @@ edition = "2021"
[dependencies] [dependencies]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.8.24" serde_yaml = "0.8.24"
serde_json = "1.0"
derive-getters = "0.2.0" derive-getters = "0.2.0"
async-imap = "0.6.0" async-imap = "0.6.0"
mailparse = "0.13.8" mailparse = "0.13.8"
@ -21,6 +22,8 @@ itertools = "0.10.4"
fern = "0.6.1" fern = "0.6.1"
chrono = "0.4.22" chrono = "0.4.22"
regex = "1.6" regex = "1.6"
reqwest = { version = "0.11.12", features = ["json"] }
url = "2.2"
# For annoying reasons, we must pin exactly the same versions as async-imap if we want to use # For annoying reasons, we must pin exactly the same versions as async-imap if we want to use
# their types. # their types.

View file

@ -135,6 +135,14 @@ impl YNABAccount {
Parser::TD => Box::new(TDEmailParser::new()), Parser::TD => Box::new(TDEmailParser::new()),
} }
} }
#[must_use]
pub fn parser_name(&self) -> &str {
match self.parser {
Parser::Citi => "Citi",
Parser::TD => "TD",
}
}
} }
mod defaults { mod defaults {

View file

@ -9,7 +9,6 @@ use async_native_tls::TlsStream;
use async_std::net::TcpStream; use async_std::net::TcpStream;
use thiserror::Error; use thiserror::Error;
pub use config::{Config, IMAP as IMAPConfig};
pub use email::inbox::WatchError; pub use email::inbox::WatchError;
pub use email::Message; pub use email::Message;
@ -22,12 +21,13 @@ use task::{Spawn, SpawnError};
const CHANNEL_SIZE: usize = 16; const CHANNEL_SIZE: usize = 16;
mod config; pub mod config;
mod email; mod email;
pub mod parse; pub mod parse;
pub mod task; pub mod task;
#[cfg(test)] #[cfg(test)]
mod testutil; mod testutil;
pub mod ynab;
type IMAPTransportStream = TlsStream<TcpStream>; type IMAPTransportStream = TlsStream<TcpStream>;
type IMAPClient = async_imap::Client<IMAPTransportStream>; type IMAPClient = async_imap::Client<IMAPTransportStream>;
@ -59,7 +59,7 @@ impl From<email::StreamSetupError> for StreamSetupError {
/// for more details. /// for more details.
pub async fn stream_new_messages<S>( pub async fn stream_new_messages<S>(
spawner: Arc<S>, spawner: Arc<S>,
imap_config: IMAPConfig, imap_config: config::IMAP,
) -> Result<impl Stream<Item = Message> + Send, StreamSetupError> ) -> Result<impl Stream<Item = Message> + Send, StreamSetupError>
where where
S: Spawn + Send + Sync + Unpin + 'static, S: Spawn + Send + Sync + Unpin + 'static,

View file

@ -5,12 +5,12 @@ use futures::{stream::StreamExt, Future};
use log::LevelFilter; use log::LevelFilter;
use std::{fs::File, sync::Arc}; use std::{fs::File, sync::Arc};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use ynabifier::parse::{Transaction, TransactionEmailParser}; use ynabifier::parse::Transaction;
use ynabifier::Message;
use ynabifier::{ use ynabifier::{
parse::{CitiEmailParser, TDEmailParser}, config::{Config, YNABAccount},
task::{Cancel, Spawn, SpawnError}, task::{Cancel, Spawn, SpawnError},
Config, ynab::Client as YNABClient,
Message,
}; };
fn main() { fn main() {
@ -21,29 +21,29 @@ fn main() {
setup_logger(config.log_level()).expect("failed to seutp logger"); setup_logger(config.log_level()).expect("failed to seutp logger");
let runtime = Runtime::new().expect("failed to create runtime"); let runtime = Runtime::new().expect("failed to create runtime");
let parsers = vec![
(
"citi",
Box::new(CitiEmailParser) as Box<dyn TransactionEmailParser>,
),
(
"td",
Box::new(TDEmailParser) as Box<dyn TransactionEmailParser>,
),
];
runtime.block_on(async move { runtime.block_on(async move {
let ynab_client = YNABClient::new(config.ynab().personal_access_token().to_string());
let mut stream = let mut stream =
ynabifier::stream_new_messages(Arc::new(TokioSpawner), config.imap().clone()) ynabifier::stream_new_messages(Arc::new(TokioSpawner), config.imap().clone())
.await .await
.expect("failed to setup stream"); .expect("failed to setup stream");
let accounts = config.ynab().accounts();
while let Some(msg) = stream.next().await { while let Some(msg) = stream.next().await {
if let Some(transaction) = try_parse_email(parsers.iter(), &msg) { if let Some((account, transaction)) = try_parse_email(accounts.iter(), &msg) {
info!( info!(
"got transaction from {} for {}", "Parsed transaction for {} to {} with parser {}",
transaction.amount(),
transaction.payee(), transaction.payee(),
transaction.amount() account.parser_name(),
);
submit_transaction(
&ynab_client,
&transaction,
config.ynab().budeget_id(),
account.id(),
) )
.await;
} }
} }
}); });
@ -68,16 +68,17 @@ fn setup_logger(log_level: LevelFilter) -> Result<(), fern::InitError> {
Ok(()) Ok(())
} }
fn try_parse_email<'a, I>(parser_iter: I, msg: &Message) -> Option<Transaction> fn try_parse_email<'a, I>(ynab_accounts: I, msg: &Message) -> Option<(&'a YNABAccount, Transaction)>
where where
I: Iterator<Item = &'a (&'a str, Box<dyn TransactionEmailParser>)>, I: Iterator<Item = &'a YNABAccount>,
{ {
for (parser_name, parser) in parser_iter { for account in ynab_accounts {
match parser.parse_transaction_email(msg) { match account.parser().parse_transaction_email(msg) {
Ok(transaction) => return Some(transaction), Ok(transaction) => return Some((account, transaction)),
Err(err) => debug!( Err(err) => debug!(
"failed to parse message with parser '{}': {:?}", "failed to parse message with parser '{}': {:?}",
parser_name, err account.parser_name(),
err
), ),
} }
} }
@ -85,6 +86,20 @@ where
None None
} }
async fn submit_transaction(
client: &YNABClient,
transaction: &Transaction,
budget_id: &str,
account_id: &str,
) {
if let Err(err) = client
.submit_transaction(transaction, budget_id, account_id)
.await
{
error!("Failed to submit transaction: {}", err);
}
}
#[derive(Clone)] #[derive(Clone)]
struct TokioSpawner; struct TokioSpawner;

View file

@ -34,6 +34,16 @@ pub trait TransactionEmailParser {
} }
impl Transaction { impl Transaction {
/// Make a new transaction for the `amount` (formatted as $dollars.cents) to the `payee` on `date`.
#[must_use]
pub fn new(payee: String, amount: String, date: NaiveDate) -> Self {
Self {
payee,
amount,
date,
}
}
#[must_use] #[must_use]
pub fn payee(&self) -> &str { pub fn payee(&self) -> &str {
&self.payee &self.payee

View file

@ -53,11 +53,7 @@ impl TransactionEmailParser for EmailParser {
let payee = find_payee_from_table_text(td_text_iter.clone())?; let payee = find_payee_from_table_text(td_text_iter.clone())?;
let date = find_date_from_table_text(td_text_iter)?; let date = find_date_from_table_text(td_text_iter)?;
let trans = Transaction { let trans = Transaction::new(payee.to_string(), amount.to_string(), date);
amount: amount.to_string(),
payee: payee.to_string(),
date,
};
Ok(trans) Ok(trans)
} }

222
src/ynab.rs Normal file
View file

@ -0,0 +1,222 @@
//! Provides a client to submit transactions to YNAB
use regex::Regex;
use reqwest::{Response, Url};
use serde::Serialize;
use thiserror::Error;
use crate::parse::Transaction;
#[derive(Error, Debug)]
pub enum Error {
#[error("Failed to parse amount '{0}': {1}")]
AmountParseFailure(String, String),
#[error("Failed to build URL: {0}")]
URLBuildFailed(url::ParseError),
#[error("Failed to build YNAB request: {0}")]
RequestBuildFailed(reqwest::Error),
#[error("Failed to send YNAB request: {0}")]
RequestFailure(reqwest::Error),
}
#[derive(Debug, Serialize)]
struct YNABTransactionRequestData<'a> {
transaction: YNABTransaction<'a>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(test, derive(PartialEq, Eq))]
struct YNABTransaction<'a> {
account_id: &'a str,
payee_name: &'a str,
date: String,
amount: i32,
}
impl<'a> YNABTransaction<'a> {
fn from_transaction_for_account(
transaction: &'a Transaction,
account_id: &'a str,
) -> Result<Self, Error> {
let ynab_amount = convert_amount_to_ynab_form(transaction.amount())?;
let ynab_transaction = YNABTransaction {
account_id,
payee_name: transaction.payee(),
date: transaction.date().format("%F").to_string(),
amount: ynab_amount,
};
Ok(ynab_transaction)
}
}
/// Client is a YNAB client that authenticates with a personal access token.
pub struct Client {
access_token: String,
http_client: reqwest::Client,
}
impl Client {
/// Make a new `Client` that will authenticate with the given personal access token.
#[must_use]
pub fn new(access_token: String) -> Self {
Self {
access_token,
http_client: reqwest::Client::new(),
}
}
/// Submit a transaction to YNAB for the given account ID.
///
/// # Errors
/// An Error is returned under the following circumstances
/// - The transaction cannot be serialized to a response properly ([`Error::AmountParseFailure`]).
/// - A request cannot be successfully built ([`Error::URLBuildFailure`] or [`Error::RequestBuildFailure`])
/// - There is a general HTTP failure ([`Error::RequestFailure`])
pub async fn submit_transaction(
&self,
transaction: &Transaction,
budget_id: &str,
account_id: &str,
) -> Result<(), Error> {
let ynab_transaction =
YNABTransaction::from_transaction_for_account(transaction, account_id)?;
let request_data = YNABTransactionRequestData {
transaction: ynab_transaction,
};
let transaction_url = Self::build_transaction_url(budget_id)?;
debug!(
"Sending YNAB request to {} for budget id {} and account id {}",
transaction_url, budget_id, account_id
);
let request = self
.http_client
.post(transaction_url)
.header("Authorization", format!("Bearer {}", self.access_token))
.header("Content-Type", "application/json")
.json(&request_data)
.build()
.map_err(Error::RequestBuildFailed)?;
let response = self
.http_client
.execute(request)
.await
.map_err(Error::RequestFailure)?;
Self::log_and_error_for_status(response)
.await
.map_err(Error::RequestFailure)?;
info!(
"Submitted transaction for {} to {} to YNAB",
transaction.amount(),
transaction.payee()
);
Ok(())
}
fn build_transaction_url(budget_id: &str) -> Result<Url, Error> {
const BASE_URL: &str = "https://api.youneedabudget.com/v1/budgets/";
// const BASE_URL: &str = "http://localhost:8080/v1/budgets/";
Url::parse(BASE_URL)
.expect("parsing ynab base url failed")
.join(format!("{}/", budget_id).as_ref())
.and_then(|url| url.join("transactions"))
.map_err(Error::URLBuildFailed)
}
async fn log_and_error_for_status(res: Response) -> Result<(), reqwest::Error> {
let status_code = res.status();
match res.error_for_status_ref() {
Ok(_) => Ok(()),
Err(err) => {
let body = res.text().await?;
error!("Got status code {}; response: {}", status_code, body);
Err(err)
}
}
}
}
fn convert_amount_to_ynab_form(amount: &str) -> Result<i32, Error> {
let pattern = Regex::new(r"^\$?(\d+)\.(\d\d)$").unwrap();
let captures = pattern.captures(amount).ok_or_else(|| {
Error::AmountParseFailure(amount.to_string(), "Amount is malformed".to_string())
})?;
let get_i32_capture_group = |n| captures.get(n).unwrap().as_str().parse::<i32>();
let dollars = get_i32_capture_group(1).map_err(|err| {
Error::AmountParseFailure(
amount.to_string(),
format!("could not parse dollars - {:?}", err),
)
})?;
let cents = get_i32_capture_group(2).map_err(|err| {
Error::AmountParseFailure(
amount.to_string(),
format!("could not parse cents - {:?}", err),
)
})?;
let converted = dollars
.checked_mul(100)
.and_then(|dollar_cents| dollar_cents.checked_add(cents))
.and_then(|total_cents| total_cents.checked_mul(10))
.and_then(i32::checked_neg)
.ok_or_else(|| {
Error::AmountParseFailure(
amount.to_string(),
"Overflow occurred during parsing of the transaction amount".to_string(),
)
})?;
Ok(converted)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use test_case::test_case;
#[test_case("$4.50", -4500)]
#[test_case("$1.00", -1000)]
#[test_case("4.50", -4500; "no dollar sign")]
fn test_converts_dollar_amount_to_ynab_form(raw: &str, expected: i32) {
let converted = convert_amount_to_ynab_form(raw).expect("failed to convert");
assert_eq!(converted, expected);
}
#[test_case("some garbage")]
#[test_case("$100000000.50"; "overflows during conversion")]
fn test_convert_amount_failure(raw: &str) {
convert_amount_to_ynab_form(raw).expect_err("should not have succeeded");
}
#[test]
fn test_convert_transaction_for_request() {
let transaction = Transaction::new(
"Ferris, LLC".to_string(),
"$10.00".to_string(),
NaiveDate::from_ymd(2022, 10, 8),
);
let account_id = "b1a7701d-1eee-4cf1-b101-5011d1f1ab1e";
let ynab_transaction =
YNABTransaction::from_transaction_for_account(&transaction, account_id)
.expect("failed to create ynab transaction");
let expected = YNABTransaction {
payee_name: "Ferris, LLC",
account_id,
date: "2022-10-08".to_string(),
amount: -10000,
};
assert_eq!(ynab_transaction, expected);
}
}