From 1f4899feb34778fb9eeb6f95e708b18defb812ea Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Thu, 13 Mar 2025 20:02:35 -0500 Subject: [PATCH] receipts: Create/update Firefly III transactions The Add Receipt form can now create or update transactions in Firefly III in certain circumstances: * For existing transactions, if the description, amount, or notes submitted on the form differ from the corresponding values in Firefly, the Firefly transaction will be updated with the submitted information * For gas station transactions, since Chase does not send useful notifications about these, there is now an option to create an entirely new transaction in Firefly, using the values provided in the form * Similarly for refunds and deposits, which we also do not get helpful notifications about, the values in the form will be used to create a new transaction in Firefly This functionality should help cover most of the edge cases that `xactmon` cannot handle. --- js/receipt-form.ts | 4 +- src/config.rs | 1 + src/firefly.rs | 134 ++++++++++++++++++++++++++++++- src/routes/receipts.rs | 130 +++++++++++++++++++++++++----- templates/receipt-form.html.tera | 3 +- 5 files changed, 243 insertions(+), 29 deletions(-) diff --git a/js/receipt-form.ts b/js/receipt-form.ts index 30c29e2..b55015b 100644 --- a/js/receipt-form.ts +++ b/js/receipt-form.ts @@ -135,7 +135,7 @@ async function fetchTransactions() { return; } xactselect.placeholder = "Select existing transaction"; - xactselect.disabled = false; + let prev = xactselect.firstChild; for (const xact of await r.json()) { const option = document.createElement("sl-option"); option.value = xact.id; @@ -143,7 +143,7 @@ async function fetchTransactions() { option.dataset.amount = xact.amount; option.dataset.date = xact.date.split("T")[0]; option.dataset.vendor = xact.description; - xactselect.appendChild(option); + xactselect.insertBefore(option, prev); } } diff --git a/src/config.rs b/src/config.rs index 4ade9a8..aa62017 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ pub struct FireflyConfig { pub url: String, pub token: PathBuf, pub search_query: String, + pub default_account: String, } #[derive(Debug, Deserialize)] diff --git a/src/firefly.rs b/src/firefly.rs index 4d1add7..a762b8e 100644 --- a/src/firefly.rs +++ b/src/firefly.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, FixedOffset}; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -8,7 +9,7 @@ pub struct Firefly { client: reqwest::Client, } -#[derive(Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct TransactionSplit { pub transaction_journal_id: String, pub date: chrono::DateTime, @@ -17,13 +18,13 @@ pub struct TransactionSplit { pub notes: Option, } -#[derive(Deserialize)] +#[derive(Clone, Deserialize)] pub struct TransactionAttributes { pub group_title: Option, pub transactions: Vec, } -#[derive(Deserialize)] +#[derive(Clone, Deserialize)] pub struct TransactionRead { pub id: String, pub attributes: TransactionAttributes, @@ -34,7 +35,7 @@ pub struct TransactionArray { pub data: Vec, } -#[derive(Deserialize)] +#[derive(Clone, Deserialize)] pub struct TransactionSingle { pub data: TransactionRead, } @@ -43,6 +44,8 @@ pub struct TransactionSingle { pub struct TransactionSplitUpdate { pub transaction_journal_id: String, pub amount: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] pub notes: Option, } @@ -51,6 +54,34 @@ pub struct TransactionUpdate { pub transactions: Vec, } +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TransactionTypeProperty { + Withdrawal, + Deposit, +} + +#[derive(Serialize)] +pub struct TransactionSplitStore { + #[serde(rename = "type")] + pub type_: TransactionTypeProperty, + pub date: DateTime, + pub amount: String, + pub description: String, + pub notes: Option, + pub source_name: Option, + pub destination_name: Option, +} + +#[derive(Serialize)] +pub struct TransactionStore { + pub error_if_duplicate_hash: bool, + pub apply_rules: bool, + pub fire_webhooks: bool, + pub group_title: String, + pub transactions: Vec, +} + #[derive(Serialize)] #[non_exhaustive] enum AttachableType { @@ -90,6 +121,88 @@ impl TransactionRead { } } +impl From for TransactionUpdate { + fn from(t: TransactionRead) -> Self { + let transactions: Vec<_> = t + .attributes + .transactions + .into_iter() + .map(|s| TransactionSplitUpdate { + transaction_journal_id: s.transaction_journal_id, + amount: s.amount, + description: s.description, + notes: s.notes, + }) + .collect(); + TransactionUpdate { transactions } + } +} + +impl TransactionStore { + pub fn new_deposit( + date: DateTime, + amount: A, + description: D, + source_account: S, + destination_account: T, + notes: N, + ) -> Self + where + A: Into, + D: Into, + S: Into>, + T: Into>, + N: Into>, + { + Self { + error_if_duplicate_hash: true, + apply_rules: true, + fire_webhooks: true, + group_title: Default::default(), + transactions: vec![TransactionSplitStore { + type_: TransactionTypeProperty::Deposit, + date, + amount: amount.into(), + description: description.into(), + source_name: source_account.into(), + destination_name: destination_account.into(), + notes: notes.into(), + }], + } + } + pub fn new_withdrawal( + date: DateTime, + amount: A, + description: D, + source_account: S, + destination_account: T, + notes: N, + ) -> Self + where + A: Into, + D: Into, + S: Into>, + T: Into>, + N: Into>, + { + Self { + error_if_duplicate_hash: true, + apply_rules: true, + fire_webhooks: true, + group_title: Default::default(), + transactions: vec![TransactionSplitStore { + type_: TransactionTypeProperty::Withdrawal, + date, + amount: amount.into(), + description: description.into(), + source_name: source_account.into(), + destination_name: destination_account.into(), + notes: notes.into(), + }], + } + } +} + impl Firefly { pub fn new(url: impl Into, token: &str) -> Self { let mut headers = HeaderMap::new(); @@ -182,4 +295,17 @@ impl Firefly { res.error_for_status_ref()?; Ok(()) } + + pub async fn create_transaction( + &self, + post: TransactionStore, + ) -> Result { + let url = format!("{}/api/v1/transactions", self.url); + let res = self.client.post(url).json(&post).send().await?; + if let Err(e) = res.error_for_status_ref() { + error!("Failed to create transaction: {:?}", res.text().await); + return Err(e); + } + res.json().await + } } diff --git a/src/routes/receipts.rs b/src/routes/receipts.rs index 000564d..43b115c 100644 --- a/src/routes/receipts.rs +++ b/src/routes/receipts.rs @@ -4,8 +4,10 @@ use rocket::serde::json::Json; use rocket::{Route, State}; use rocket_db_pools::Connection as DatabaseConnection; use rocket_dyn_templates::{context, Template}; -use tracing::{error, info}; +use rust_decimal::prelude::ToPrimitive; +use tracing::{debug, error, info}; +use crate::firefly::{TransactionStore, TransactionUpdate}; use crate::receipts::*; use crate::{Context, Database}; @@ -70,34 +72,114 @@ pub async fn add_receipt( ); }, }; - if let Some(id) = &form.transaction { - match ctx.firefly.get_transaction(id).await { + let xact = match form.transaction { + Some(ref s) if s == "new" => { + let data = TransactionStore::new_withdrawal( + data.date, + data.amount.to_string(), + data.vendor, + ctx.config.firefly.default_account.clone(), + Some("(no name)".into()), + data.notes, + ); + match ctx.firefly.create_transaction(data).await { + Ok(t) => { + info!("Successfully created transaction ID {}", t.data.id); + Some(t) + }, + Err(e) => { + error!("Failed to create Firefly transaction: {}", e); + None + }, + } + }, + Some(ref s) if s == "deposit" => { + let data = TransactionStore::new_deposit( + data.date, + data.amount.to_string(), + data.vendor, + Some("(no name)".into()), + ctx.config.firefly.default_account.clone(), + data.notes, + ); + match ctx.firefly.create_transaction(data).await { + Ok(t) => { + info!("Successfully created transaction ID {}", t.data.id); + Some(t) + }, + Err(e) => { + error!("Failed to create Firefly transaction: {}", e); + None + }, + } + }, + Some(ref id) => match ctx.firefly.get_transaction(id).await { Ok(t) => { - if let Some(j) = t.data.attributes.transactions.first() { - if let Err(e) = ctx - .firefly - .attach_receipt( - &j.transaction_journal_id, - data.photo, - data.filename, - ) - .await - { - error!( - "Failed to attach receipt to Firefly transaction {}: {}", - id, e - ); + let mut needs_update = false; + let mut update = TransactionUpdate::from(t.data.clone()); + let amount = t.data.amount(); + if let Some(split) = update.transactions.last_mut() { + if data.amount.to_f64() != Some(amount) { + split.amount = data.amount.to_string(); + needs_update = true; + } + if data.vendor != split.description { + split.description = data.vendor; + needs_update = true; + } + if !data.notes.is_empty() { + split.notes = Some(data.notes.clone()); + needs_update = true; } } else { - error!( - "Could not attach receipt to Firefly transaction {}: no splits", - id - ); + debug!("Transaction {} has no splits", id); + } + if needs_update { + let res = ctx + .firefly + .update_transaction(&t.data.id, &update) + .await; + match res { + Ok(t) => { + info!("Successfully updated transaction {}", id); + Some(t) + }, + Err(e) => { + error!( + "Failed to update trancation {}: {}", + id, e + ); + Some(t) + }, + } + } else { + debug!("Transaction {} does not need updated", id); + Some(t) } }, Err(e) => { error!("Could not load Firefly transaction {}: {}", id, e); + None }, + }, + None => None, + }; + if let Some(xact) = xact { + if let Some(t) = xact.data.attributes.transactions.first() { + if let Err(e) = ctx + .firefly + .attach_receipt( + &t.transaction_journal_id, + data.photo, + data.filename, + ) + .await + { + error!( + "Failed to attach receipt to Firefly transaction: {}", + e + ); + } } } (Status::Ok, Json(AddReceiptResponse::Success(receipt))) @@ -128,7 +210,11 @@ pub struct PhotoResponse { impl PhotoResponse { fn new(content: Vec, content_type: ContentType) -> Self { let cache_control = Header::new("Cache-Control", "max-age=604800"); - Self { content, content_type, cache_control } + Self { + content, + content_type, + cache_control, + } } } diff --git a/templates/receipt-form.html.tera b/templates/receipt-form.html.tera index eff9fea..c4b8007 100644 --- a/templates/receipt-form.html.tera +++ b/templates/receipt-form.html.tera @@ -16,10 +16,11 @@ name="transaction" placeholder="Loading transactions …" help-text="Select an existing transaction to auto fill fields" - disabled clearable > + New Gas Station Transaction … + New Refund/Deposit Transaction …