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 …