receipts: Create/update Firefly III transactions
dustin/receipts/pipeline/head This commit looks good Details

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.
bugfix/ci-buildah
Dustin 2025-03-13 20:02:35 -05:00
parent 4060dea44b
commit 1f4899feb3
5 changed files with 243 additions and 29 deletions

View File

@ -135,7 +135,7 @@ async function fetchTransactions() {
return; return;
} }
xactselect.placeholder = "Select existing transaction"; xactselect.placeholder = "Select existing transaction";
xactselect.disabled = false; let prev = xactselect.firstChild;
for (const xact of await r.json()) { for (const xact of await r.json()) {
const option = document.createElement("sl-option"); const option = document.createElement("sl-option");
option.value = xact.id; option.value = xact.id;
@ -143,7 +143,7 @@ async function fetchTransactions() {
option.dataset.amount = xact.amount; option.dataset.amount = xact.amount;
option.dataset.date = xact.date.split("T")[0]; option.dataset.date = xact.date.split("T")[0];
option.dataset.vendor = xact.description; option.dataset.vendor = xact.description;
xactselect.appendChild(option); xactselect.insertBefore(option, prev);
} }
} }

View File

@ -7,6 +7,7 @@ pub struct FireflyConfig {
pub url: String, pub url: String,
pub token: PathBuf, pub token: PathBuf,
pub search_query: String, pub search_query: String,
pub default_account: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View File

@ -1,3 +1,4 @@
use chrono::{DateTime, FixedOffset};
use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -8,7 +9,7 @@ pub struct Firefly {
client: reqwest::Client, client: reqwest::Client,
} }
#[derive(Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct TransactionSplit { pub struct TransactionSplit {
pub transaction_journal_id: String, pub transaction_journal_id: String,
pub date: chrono::DateTime<chrono::FixedOffset>, pub date: chrono::DateTime<chrono::FixedOffset>,
@ -17,13 +18,13 @@ pub struct TransactionSplit {
pub notes: Option<String>, pub notes: Option<String>,
} }
#[derive(Deserialize)] #[derive(Clone, Deserialize)]
pub struct TransactionAttributes { pub struct TransactionAttributes {
pub group_title: Option<String>, pub group_title: Option<String>,
pub transactions: Vec<TransactionSplit>, pub transactions: Vec<TransactionSplit>,
} }
#[derive(Deserialize)] #[derive(Clone, Deserialize)]
pub struct TransactionRead { pub struct TransactionRead {
pub id: String, pub id: String,
pub attributes: TransactionAttributes, pub attributes: TransactionAttributes,
@ -34,7 +35,7 @@ pub struct TransactionArray {
pub data: Vec<TransactionRead>, pub data: Vec<TransactionRead>,
} }
#[derive(Deserialize)] #[derive(Clone, Deserialize)]
pub struct TransactionSingle { pub struct TransactionSingle {
pub data: TransactionRead, pub data: TransactionRead,
} }
@ -43,6 +44,8 @@ pub struct TransactionSingle {
pub struct TransactionSplitUpdate { pub struct TransactionSplitUpdate {
pub transaction_journal_id: String, pub transaction_journal_id: String,
pub amount: String, pub amount: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>, pub notes: Option<String>,
} }
@ -51,6 +54,34 @@ pub struct TransactionUpdate {
pub transactions: Vec<TransactionSplitUpdate>, pub transactions: Vec<TransactionSplitUpdate>,
} }
#[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<FixedOffset>,
pub amount: String,
pub description: String,
pub notes: Option<String>,
pub source_name: Option<String>,
pub destination_name: Option<String>,
}
#[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<TransactionSplitStore>,
}
#[derive(Serialize)] #[derive(Serialize)]
#[non_exhaustive] #[non_exhaustive]
enum AttachableType { enum AttachableType {
@ -90,6 +121,88 @@ impl TransactionRead {
} }
} }
impl From<TransactionRead> 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<A, D, S, T, N>(
date: DateTime<FixedOffset>,
amount: A,
description: D,
source_account: S,
destination_account: T,
notes: N,
) -> Self
where
A: Into<String>,
D: Into<String>,
S: Into<Option<String>>,
T: Into<Option<String>>,
N: Into<Option<String>>,
{
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<A, D, S, T, N>(
date: DateTime<FixedOffset>,
amount: A,
description: D,
source_account: S,
destination_account: T,
notes: N,
) -> Self
where
A: Into<String>,
D: Into<String>,
S: Into<Option<String>>,
T: Into<Option<String>>,
N: Into<Option<String>>,
{
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 { impl Firefly {
pub fn new(url: impl Into<String>, token: &str) -> Self { pub fn new(url: impl Into<String>, token: &str) -> Self {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
@ -182,4 +295,17 @@ impl Firefly {
res.error_for_status_ref()?; res.error_for_status_ref()?;
Ok(()) Ok(())
} }
pub async fn create_transaction(
&self,
post: TransactionStore,
) -> Result<TransactionSingle, reqwest::Error> {
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
}
} }

View File

@ -4,8 +4,10 @@ use rocket::serde::json::Json;
use rocket::{Route, State}; use rocket::{Route, State};
use rocket_db_pools::Connection as DatabaseConnection; use rocket_db_pools::Connection as DatabaseConnection;
use rocket_dyn_templates::{context, Template}; 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::receipts::*;
use crate::{Context, Database}; use crate::{Context, Database};
@ -70,34 +72,114 @@ pub async fn add_receipt(
); );
}, },
}; };
if let Some(id) = &form.transaction { let xact = match form.transaction {
match ctx.firefly.get_transaction(id).await { 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) => { Ok(t) => {
if let Some(j) = t.data.attributes.transactions.first() { let mut needs_update = false;
if let Err(e) = ctx let mut update = TransactionUpdate::from(t.data.clone());
.firefly let amount = t.data.amount();
.attach_receipt( if let Some(split) = update.transactions.last_mut() {
&j.transaction_journal_id, if data.amount.to_f64() != Some(amount) {
data.photo, split.amount = data.amount.to_string();
data.filename, needs_update = true;
) }
.await if data.vendor != split.description {
{ split.description = data.vendor;
error!( needs_update = true;
"Failed to attach receipt to Firefly transaction {}: {}", }
id, e if !data.notes.is_empty() {
); split.notes = Some(data.notes.clone());
needs_update = true;
} }
} else { } else {
error!( debug!("Transaction {} has no splits", id);
"Could not attach receipt to Firefly transaction {}: 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) => { Err(e) => {
error!("Could not load Firefly transaction {}: {}", id, 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))) (Status::Ok, Json(AddReceiptResponse::Success(receipt)))
@ -128,7 +210,11 @@ pub struct PhotoResponse {
impl PhotoResponse { impl PhotoResponse {
fn new(content: Vec<u8>, content_type: ContentType) -> Self { fn new(content: Vec<u8>, content_type: ContentType) -> Self {
let cache_control = Header::new("Cache-Control", "max-age=604800"); let cache_control = Header::new("Cache-Control", "max-age=604800");
Self { content, content_type, cache_control } Self {
content,
content_type,
cache_control,
}
} }
} }

View File

@ -16,10 +16,11 @@
name="transaction" name="transaction"
placeholder="Loading transactions …" placeholder="Loading transactions …"
help-text="Select an existing transaction to auto fill fields" help-text="Select an existing transaction to auto fill fields"
disabled
clearable clearable
> >
<sl-spinner slot="suffix"></sl-spinner> <sl-spinner slot="suffix"></sl-spinner>
<sl-option value="new">New Gas Station Transaction …</sl-option>
<sl-option value="deposit">New Refund/Deposit Transaction …</sl-option>
</sl-select> </sl-select>
</p> </p>
<p> <p>