From ad1c857c970204a06ccca6b94943087904256235 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Thu, 8 May 2025 22:25:29 -0500 Subject: [PATCH] receipt-form: Add Restaurant toggle The _Add Receipt_ form now has a _Restaurant_ toggle. When uploading a receipt that creates or updates a Firefly III transaction, if the toggle is activated, a special tag will be added to the transaction. The assumption is that Firefly will have a rule to automatically adjust the destination account, category, and/or budget for the transaction if this tag is present. The tag is configurable and defaults to `Food & Drink`. --- js/receipt-form.ts | 7 ++++++- src/config.rs | 6 ++++++ src/firefly.rs | 19 +++++++++++++++---- src/receipts.rs | 4 ++++ src/routes/receipts.rs | 30 +++++++++++++++++++++++++++++- src/routes/transactions.rs | 16 ++++++++++------ src/transactions.rs | 20 ++++++++++++++++---- templates/receipt-form.html.tera | 1 + 8 files changed, 87 insertions(+), 16 deletions(-) diff --git a/js/receipt-form.ts b/js/receipt-form.ts index b55015b..1d9fb54 100644 --- a/js/receipt-form.ts +++ b/js/receipt-form.ts @@ -5,6 +5,7 @@ import "@shoelace-style/shoelace/dist/components/input/input.js"; import "@shoelace-style/shoelace/dist/components/option/option.js"; import "@shoelace-style/shoelace/dist/components/spinner/spinner.js"; import "@shoelace-style/shoelace/dist/components/select/select.js"; +import "@shoelace-style/shoelace/dist/components/switch/switch.js"; import "@shoelace-style/shoelace/dist/components/textarea/textarea.js"; import "./shoelace.js"; @@ -14,6 +15,7 @@ import "./camera.ts"; import CameraInput from "./camera.ts"; import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js"; import SlSelect from "@shoelace-style/shoelace/dist/components/select/select.js"; +import SlSwitch from "@shoelace-style/shoelace/dist/components/switch/switch.js"; import { notify, notifyError } from "./alert"; import { getResponseError } from "./ajaxUtil.js"; @@ -32,7 +34,7 @@ const xactselect = document.getElementById("transactions") as SlSelect; let dirty = false; -window.addEventListener("beforeunload", function(evt) { +window.addEventListener("beforeunload", function (evt) { if (dirty) { evt.preventDefault(); } @@ -143,6 +145,7 @@ async function fetchTransactions() { option.dataset.amount = xact.amount; option.dataset.date = xact.date.split("T")[0]; option.dataset.vendor = xact.description; + option.dataset.is_restaurant = xact.is_restaurant; xactselect.insertBefore(option, prev); } } @@ -166,6 +169,8 @@ xactselect.addEventListener("sl-change", () => { } } }); + (form.querySelector("[name='is_restaurant']") as SlSwitch).checked = + option.dataset.is_restaurant == "true"; }); fetchTransactions(); diff --git a/src/config.rs b/src/config.rs index aa62017..c10a2ed 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,9 +8,15 @@ pub struct FireflyConfig { pub token: PathBuf, pub search_query: String, pub default_account: String, + #[serde(default = "default_restaurant_tag")] + pub restaurant_tag: String } #[derive(Debug, Deserialize)] pub struct Config { pub firefly: FireflyConfig, } + +fn default_restaurant_tag() -> String { + "Food & Drink".into() +} diff --git a/src/firefly.rs b/src/firefly.rs index a762b8e..c7d9e23 100644 --- a/src/firefly.rs +++ b/src/firefly.rs @@ -16,6 +16,7 @@ pub struct TransactionSplit { pub amount: String, pub description: String, pub notes: Option, + pub tags: Option>, } #[derive(Clone, Deserialize)] @@ -40,16 +41,18 @@ pub struct TransactionSingle { pub data: TransactionRead, } -#[derive(Serialize)] +#[derive(Debug, Serialize)] pub struct TransactionSplitUpdate { pub transaction_journal_id: String, pub amount: String, pub description: String, #[serde(skip_serializing_if = "Option::is_none")] pub notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, } -#[derive(Serialize)] +#[derive(Debug, Serialize)] pub struct TransactionUpdate { pub transactions: Vec, } @@ -71,6 +74,7 @@ pub struct TransactionSplitStore { pub notes: Option, pub source_name: Option, pub destination_name: Option, + pub tags: Option>, } #[derive(Serialize)] @@ -132,6 +136,7 @@ impl From for TransactionUpdate { amount: s.amount, description: s.description, notes: s.notes, + tags: s.tags, }) .collect(); TransactionUpdate { transactions } @@ -139,13 +144,14 @@ impl From for TransactionUpdate { } impl TransactionStore { - pub fn new_deposit( + pub fn new_deposit( date: DateTime, amount: A, description: D, source_account: S, destination_account: T, notes: N, + tags: G, ) -> Self where A: Into, @@ -153,6 +159,7 @@ impl TransactionStore { S: Into>, T: Into>, N: Into>, + G: Into>>, { Self { error_if_duplicate_hash: true, @@ -167,16 +174,18 @@ impl TransactionStore { source_name: source_account.into(), destination_name: destination_account.into(), notes: notes.into(), + tags: tags.into(), }], } } - pub fn new_withdrawal( + pub fn new_withdrawal( date: DateTime, amount: A, description: D, source_account: S, destination_account: T, notes: N, + tags: G, ) -> Self where A: Into, @@ -184,6 +193,7 @@ impl TransactionStore { S: Into>, T: Into>, N: Into>, + G: Into>>, { Self { error_if_duplicate_hash: true, @@ -198,6 +208,7 @@ impl TransactionStore { source_name: source_account.into(), destination_name: destination_account.into(), notes: notes.into(), + tags: tags.into(), }], } } diff --git a/src/receipts.rs b/src/receipts.rs index 732202b..24b5d3d 100644 --- a/src/receipts.rs +++ b/src/receipts.rs @@ -36,6 +36,7 @@ pub struct ReceiptPostForm<'r> { pub date: String, pub vendor: String, pub amount: String, + pub is_restaurant: Option, pub notes: String, pub photo: TempFile<'r>, } @@ -44,6 +45,7 @@ pub struct ReceiptPostData { pub date: DateTime, pub vendor: String, pub amount: Decimal, + pub is_restaurant: bool, pub notes: String, pub filename: String, pub photo: Vec, @@ -86,6 +88,7 @@ impl ReceiptPostData { let vendor = form.vendor.clone(); use rust_decimal::prelude::FromStr; let amount = Decimal::from_str(&form.amount)?; + let is_restaurant = form.is_restaurant.unwrap_or_default(); let notes = form.notes.clone(); let stream = form.photo.open().await?; let mut reader = BufReader::new(stream); @@ -116,6 +119,7 @@ impl ReceiptPostData { date, vendor, amount, + is_restaurant, notes, filename, photo, diff --git a/src/routes/receipts.rs b/src/routes/receipts.rs index d2e6677..7923d6d 100644 --- a/src/routes/receipts.rs +++ b/src/routes/receipts.rs @@ -5,7 +5,7 @@ use rocket::{Route, State}; use rocket_db_pools::Connection as DatabaseConnection; use rocket_dyn_templates::{context, Template}; use rust_decimal::prelude::ToPrimitive; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, trace}; use crate::firefly::{TransactionStore, TransactionUpdate}; use crate::imaging; @@ -75,6 +75,11 @@ pub async fn add_receipt( }; let xact = match form.transaction { Some(ref s) if s == "new" => { + let tags = if data.is_restaurant { + Some(vec![ctx.config.firefly.restaurant_tag.clone()]) + } else { + None + }; let data = TransactionStore::new_withdrawal( data.date, data.amount.to_string(), @@ -82,6 +87,7 @@ pub async fn add_receipt( ctx.config.firefly.default_account.clone(), Some("(no name)".into()), data.notes, + tags, ); match ctx.firefly.create_transaction(data).await { Ok(t) => { @@ -102,6 +108,7 @@ pub async fn add_receipt( Some("(no name)".into()), ctx.config.firefly.default_account.clone(), data.notes, + None, ); match ctx.firefly.create_transaction(data).await { Ok(t) => { @@ -143,6 +150,27 @@ pub async fn add_receipt( needs_update = true; } } + if data.is_restaurant { + if let Some(tags) = &mut split.tags { + if !tags + .contains(&ctx.config.firefly.restaurant_tag) + { + tags.push( + ctx.config.firefly.restaurant_tag.clone(), + ); + needs_update = true; + } + } else { + split.tags.replace(vec![ctx + .config + .firefly + .restaurant_tag + .clone()]); + needs_update = true; + } + } + trace!("Original transaction: {:?}", split); + trace!("Updated transaction: {:?}", update); } else { debug!("Transaction {} has no splits", id); } diff --git a/src/routes/transactions.rs b/src/routes/transactions.rs index 9d6f343..81e28cf 100644 --- a/src/routes/transactions.rs +++ b/src/routes/transactions.rs @@ -21,16 +21,20 @@ async fn transaction_list( ) })?; + let restaurant_tag = Some(&ctx.config.firefly.restaurant_tag); + Ok(Json( result .data .into_iter() - .filter_map(|t| match Transaction::try_from(t) { - Ok(t) => Some(t), - Err(e) => { - error!("Error parsing transaction details: {}", e); - None - }, + .filter_map(|t| { + match Transaction::from_firefly(t, restaurant_tag) { + Ok(t) => Some(t), + Err(e) => { + error!("Error parsing transaction details: {}", e); + None + }, + } }) .collect(), )) diff --git a/src/transactions.rs b/src/transactions.rs index 266bf0e..5ab01d7 100644 --- a/src/transactions.rs +++ b/src/transactions.rs @@ -10,12 +10,14 @@ pub struct Transaction { pub amount: f64, pub description: String, pub date: DateTime, + pub is_restaurant: bool, } -impl TryFrom for Transaction { - type Error = &'static str; - - fn try_from(t: TransactionRead) -> Result { +impl Transaction { + pub fn from_firefly>( + t: TransactionRead, + restaurant_tag: Option, + ) -> Result { let first_split = match t.attributes.transactions.first() { Some(t) => t, None => { @@ -30,11 +32,21 @@ impl TryFrom for Transaction { } else { first_split.description.clone() }; + let is_restaurant = if let Some(tag) = restaurant_tag { + if let Some(tags) = &first_split.tags { + tags.iter().any(|a| a == tag.as_ref()) + } else { + false + } + } else { + false + }; Ok(Self { id: t.id, amount, description, date, + is_restaurant, }) } } diff --git a/templates/receipt-form.html.tera b/templates/receipt-form.html.tera index 98ef8f5..24da545 100644 --- a/templates/receipt-form.html.tera +++ b/templates/receipt-form.html.tera @@ -32,6 +32,7 @@ required >

+

Restaurant