Add ability to submit transactions to YNAB
parent
c739436c3a
commit
67500348b0
|
@ -467,6 +467,12 @@ dependencies = [
|
|||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
|
@ -484,11 +490,10 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
|||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191"
|
||||
checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8"
|
||||
dependencies = [
|
||||
"matches",
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
|
@ -664,6 +669,25 @@ dependencies = [
|
|||
"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]]
|
||||
name = "hashbrown"
|
||||
version = "0.11.2"
|
||||
|
@ -693,6 +717,77 @@ dependencies = [
|
|||
"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]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.50"
|
||||
|
@ -708,11 +803,10 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
|
||||
checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
|
||||
dependencies = [
|
||||
"matches",
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
@ -745,6 +839,12 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.4"
|
||||
|
@ -865,6 +965,12 @@ version = "2.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
|
@ -873,25 +979,14 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
|||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.2"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52da4364ffb0e4fe33a9841a98a3f3014fb964045ce4f7a45a398243c8d6b0c9"
|
||||
checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"miow",
|
||||
"ntapi",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miow"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -934,15 +1029,6 @@ dependencies = [
|
|||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c28774a7fd2fbb4f0babd8237ce554b73af68021b5f695a3cebd6c59bac0980f"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.45"
|
||||
|
@ -1086,9 +1172,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
|
||||
checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
|
@ -1396,6 +1482,43 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.0"
|
||||
|
@ -1512,6 +1635,29 @@ dependencies = [
|
|||
"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]]
|
||||
name = "serde_yaml"
|
||||
version = "0.8.24"
|
||||
|
@ -1782,16 +1928,16 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
|
|||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.18.1"
|
||||
version = "1.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce653fb475565de9f6fb0614b28bca8df2c430c0cf84bcd9c843f15de5414cc"
|
||||
checksum = "a9e03c497dc955702ba729190dc4aac6f2a0ce97f913e5b1b5912fc5039d9099"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"bytes",
|
||||
"libc",
|
||||
"memchr",
|
||||
"mio",
|
||||
"num_cpus",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
|
@ -1811,6 +1957,62 @@ dependencies = [
|
|||
"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]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.8"
|
||||
|
@ -1849,13 +2051,12 @@ checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04"
|
|||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.2.2"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
|
||||
checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"matches",
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
|
@ -1893,6 +2094,16 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "wasi"
|
||||
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"
|
||||
checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust"
|
||||
version = "0.4.5"
|
||||
|
@ -2096,8 +2316,10 @@ dependencies = [
|
|||
"log",
|
||||
"mailparse",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"simplelog",
|
||||
"stop-token",
|
||||
|
@ -2105,4 +2327,5 @@ dependencies = [
|
|||
"textwrap",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
|
|
@ -8,6 +8,7 @@ edition = "2021"
|
|||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.8.24"
|
||||
serde_json = "1.0"
|
||||
derive-getters = "0.2.0"
|
||||
async-imap = "0.6.0"
|
||||
mailparse = "0.13.8"
|
||||
|
@ -21,6 +22,8 @@ itertools = "0.10.4"
|
|||
fern = "0.6.1"
|
||||
chrono = "0.4.22"
|
||||
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
|
||||
# their types.
|
||||
|
|
|
@ -135,6 +135,14 @@ impl YNABAccount {
|
|||
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 {
|
||||
|
|
|
@ -9,7 +9,6 @@ use async_native_tls::TlsStream;
|
|||
use async_std::net::TcpStream;
|
||||
use thiserror::Error;
|
||||
|
||||
pub use config::{Config, IMAP as IMAPConfig};
|
||||
pub use email::inbox::WatchError;
|
||||
pub use email::Message;
|
||||
|
||||
|
@ -22,12 +21,13 @@ use task::{Spawn, SpawnError};
|
|||
|
||||
const CHANNEL_SIZE: usize = 16;
|
||||
|
||||
mod config;
|
||||
pub mod config;
|
||||
mod email;
|
||||
pub mod parse;
|
||||
pub mod task;
|
||||
#[cfg(test)]
|
||||
mod testutil;
|
||||
pub mod ynab;
|
||||
|
||||
type IMAPTransportStream = TlsStream<TcpStream>;
|
||||
type IMAPClient = async_imap::Client<IMAPTransportStream>;
|
||||
|
@ -59,7 +59,7 @@ impl From<email::StreamSetupError> for StreamSetupError {
|
|||
/// for more details.
|
||||
pub async fn stream_new_messages<S>(
|
||||
spawner: Arc<S>,
|
||||
imap_config: IMAPConfig,
|
||||
imap_config: config::IMAP,
|
||||
) -> Result<impl Stream<Item = Message> + Send, StreamSetupError>
|
||||
where
|
||||
S: Spawn + Send + Sync + Unpin + 'static,
|
||||
|
|
63
src/main.rs
63
src/main.rs
|
@ -5,12 +5,12 @@ use futures::{stream::StreamExt, Future};
|
|||
use log::LevelFilter;
|
||||
use std::{fs::File, sync::Arc};
|
||||
use tokio::runtime::Runtime;
|
||||
use ynabifier::parse::{Transaction, TransactionEmailParser};
|
||||
use ynabifier::Message;
|
||||
use ynabifier::parse::Transaction;
|
||||
use ynabifier::{
|
||||
parse::{CitiEmailParser, TDEmailParser},
|
||||
config::{Config, YNABAccount},
|
||||
task::{Cancel, Spawn, SpawnError},
|
||||
Config,
|
||||
ynab::Client as YNABClient,
|
||||
Message,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
|
@ -21,29 +21,29 @@ fn main() {
|
|||
setup_logger(config.log_level()).expect("failed to seutp logger");
|
||||
|
||||
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 {
|
||||
let ynab_client = YNABClient::new(config.ynab().personal_access_token().to_string());
|
||||
let mut stream =
|
||||
ynabifier::stream_new_messages(Arc::new(TokioSpawner), config.imap().clone())
|
||||
.await
|
||||
.expect("failed to setup stream");
|
||||
|
||||
let accounts = config.ynab().accounts();
|
||||
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!(
|
||||
"got transaction from {} for {}",
|
||||
"Parsed transaction for {} to {} with parser {}",
|
||||
transaction.amount(),
|
||||
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(())
|
||||
}
|
||||
|
||||
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
|
||||
I: Iterator<Item = &'a (&'a str, Box<dyn TransactionEmailParser>)>,
|
||||
I: Iterator<Item = &'a YNABAccount>,
|
||||
{
|
||||
for (parser_name, parser) in parser_iter {
|
||||
match parser.parse_transaction_email(msg) {
|
||||
Ok(transaction) => return Some(transaction),
|
||||
for account in ynab_accounts {
|
||||
match account.parser().parse_transaction_email(msg) {
|
||||
Ok(transaction) => return Some((account, transaction)),
|
||||
Err(err) => debug!(
|
||||
"failed to parse message with parser '{}': {:?}",
|
||||
parser_name, err
|
||||
account.parser_name(),
|
||||
err
|
||||
),
|
||||
}
|
||||
}
|
||||
|
@ -85,6 +86,20 @@ where
|
|||
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)]
|
||||
struct TokioSpawner;
|
||||
|
||||
|
|
10
src/parse.rs
10
src/parse.rs
|
@ -34,6 +34,16 @@ pub trait TransactionEmailParser {
|
|||
}
|
||||
|
||||
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]
|
||||
pub fn payee(&self) -> &str {
|
||||
&self.payee
|
||||
|
|
|
@ -53,11 +53,7 @@ impl TransactionEmailParser for EmailParser {
|
|||
let payee = find_payee_from_table_text(td_text_iter.clone())?;
|
||||
let date = find_date_from_table_text(td_text_iter)?;
|
||||
|
||||
let trans = Transaction {
|
||||
amount: amount.to_string(),
|
||||
payee: payee.to_string(),
|
||||
date,
|
||||
};
|
||||
let trans = Transaction::new(payee.to_string(), amount.to_string(), date);
|
||||
|
||||
Ok(trans)
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue