Add ability to submit transactions to YNAB

master
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",
]
[[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",
]

View File

@ -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.

View File

@ -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 {

View File

@ -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,

View File

@ -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;

View File

@ -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

View File

@ -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)
}

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);
}
}