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

View File

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

View File

@ -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<chrono::FixedOffset>,
@ -17,13 +18,13 @@ pub struct TransactionSplit {
pub notes: Option<String>,
}
#[derive(Deserialize)]
#[derive(Clone, Deserialize)]
pub struct TransactionAttributes {
pub group_title: Option<String>,
pub transactions: Vec<TransactionSplit>,
}
#[derive(Deserialize)]
#[derive(Clone, Deserialize)]
pub struct TransactionRead {
pub id: String,
pub attributes: TransactionAttributes,
@ -34,7 +35,7 @@ pub struct TransactionArray {
pub data: Vec<TransactionRead>,
}
#[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<String>,
}
@ -51,6 +54,34 @@ pub struct TransactionUpdate {
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)]
#[non_exhaustive]
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 {
pub fn new(url: impl Into<String>, 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<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_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) => {
if let Some(j) = t.data.attributes.transactions.first() {
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) => {
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 {
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(
&j.transaction_journal_id,
&t.transaction_journal_id,
data.photo,
data.filename,
)
.await
{
error!(
"Failed to attach receipt to Firefly transaction {}: {}",
id, e
"Failed to attach receipt to Firefly transaction: {}",
e
);
}
} else {
error!(
"Could not attach receipt to Firefly transaction {}: no splits",
id
);
}
},
Err(e) => {
error!("Could not load Firefly transaction {}: {}", id, e);
},
}
}
(Status::Ok, Json(AddReceiptResponse::Success(receipt)))
@ -128,7 +210,11 @@ pub struct PhotoResponse {
impl PhotoResponse {
fn new(content: Vec<u8>, 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,
}
}
}

View File

@ -16,10 +16,11 @@
name="transaction"
placeholder="Loading transactions …"
help-text="Select an existing transaction to auto fill fields"
disabled
clearable
>
<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>
</p>
<p>