receipts: Create/update Firefly III transactions
dustin/receipts/pipeline/head This commit looks good
Details
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
parent
4060dea44b
commit
1f4899feb3
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
134
src/firefly.rs
134
src/firefly.rs
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) => {
|
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
|
if let Err(e) = ctx
|
||||||
.firefly
|
.firefly
|
||||||
.attach_receipt(
|
.attach_receipt(
|
||||||
&j.transaction_journal_id,
|
&t.transaction_journal_id,
|
||||||
data.photo,
|
data.photo,
|
||||||
data.filename,
|
data.filename,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
error!(
|
error!(
|
||||||
"Failed to attach receipt to Firefly transaction {}: {}",
|
"Failed to attach receipt to Firefly transaction: {}",
|
||||||
id, e
|
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)))
|
(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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue