Attach receipts to Firefly transactions

And now we come to the meat of the thing: the ability to update
transactions and attach receipts.  Most of this is straightforward,
except for changing the amount of split transactions.  Hopefully, this
won't come up too often, since I can't really split transactions without
a receipt.  Just to be on the safe side, attempting to change the amount
of a split transaction will return an error.
bugfix/ci-buildah
Dustin 2025-03-08 18:31:46 -06:00
parent 0c6f9385e6
commit d2b93bff3b
3 changed files with 172 additions and 20 deletions

View File

@ -1,6 +1,7 @@
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::Client;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tracing::{error, info};
pub struct Firefly {
url: String,
@ -9,9 +10,11 @@ pub struct Firefly {
#[derive(Deserialize)]
pub struct TransactionSplit {
pub transaction_journal_id: String,
pub date: chrono::DateTime<chrono::FixedOffset>,
pub amount: String,
pub description: String,
pub notes: Option<String>,
}
#[derive(Deserialize)]
@ -36,6 +39,57 @@ pub struct TransactionSingle {
pub data: TransactionRead,
}
#[derive(Serialize)]
pub struct TransactionSplitUpdate {
pub transaction_journal_id: String,
pub amount: String,
pub notes: Option<String>,
}
#[derive(Serialize)]
pub struct TransactionUpdate {
pub transactions: Vec<TransactionSplitUpdate>,
}
#[derive(Serialize)]
#[non_exhaustive]
enum AttachableType {
TransactionJournal,
}
#[derive(Serialize)]
struct AttachmentStore {
filename: String,
attachable_type: AttachableType,
attachable_id: String,
}
#[derive(Deserialize)]
struct AttachmentRead {
id: String,
}
#[derive(Deserialize)]
struct AttachmentSingle {
data: AttachmentRead,
}
impl TransactionRead {
pub fn amount(&self) -> f64 {
self.attributes
.transactions
.iter()
.filter_map(|t| match t.amount.parse::<f64>() {
Ok(v) => Some(v),
Err(e) => {
error!("Invalid amount: {}", e);
None
},
})
.sum()
}
}
impl Firefly {
pub fn new(url: impl Into<String>, token: &str) -> Self {
let mut headers = HeaderMap::new();
@ -83,4 +137,49 @@ impl Firefly {
res.error_for_status_ref()?;
res.json().await
}
pub async fn update_transaction(
&self,
id: &str,
txn: &TransactionUpdate,
) -> Result<TransactionSingle, reqwest::Error> {
let url = format!("{}/api/v1/transactions/{}", self.url, id);
let res = self.client.put(url).json(txn).send().await?;
res.error_for_status_ref()?;
info!("Successfully updated transaction {}", id);
res.json().await
}
pub async fn attach_receipt(
&self,
id: &str,
content: impl Into<reqwest::Body>,
filename: impl Into<String>,
) -> Result<(), reqwest::Error> {
let filename = filename.into();
info!("Attaching receipt {} to transaction {}", filename, id);
let url = format!("{}/api/v1/attachments", self.url);
let data = AttachmentStore {
filename,
attachable_type: AttachableType::TransactionJournal,
attachable_id: id.into(),
};
let res = self.client.post(url).json(&data).send().await?;
res.error_for_status_ref()?;
let attachment: AttachmentSingle = res.json().await?;
info!("Created attachment {}", attachment.data.id);
let url = format!(
"{}/api/v1/attachments/{}/upload",
self.url, attachment.data.id
);
let res = self
.client
.post(url)
.header("Content-Type", "application/octet-stream")
.body(content)
.send()
.await?;
res.error_for_status_ref()?;
Ok(())
}
}

View File

@ -7,13 +7,16 @@ use rocket::form::Form;
use rocket::fs::{FileServer, TempFile};
use rocket::http::Status;
use rocket::response::Redirect;
use rocket::tokio::io::{AsyncReadExt, BufReader};
use rocket::State;
use rocket_dyn_templates::{context, Template};
use serde::Serialize;
use tracing::{debug, error};
use config::{Config, ConfigError};
use firefly::{Firefly, TransactionRead};
use firefly::{
Firefly, TransactionRead, TransactionSplitUpdate, TransactionUpdate,
};
struct Context {
#[allow(dead_code)]
@ -47,7 +50,7 @@ pub struct Transaction {
#[derive(rocket::FromForm)]
struct TransactionPostData<'r> {
amount: f32,
amount: f64,
notes: String,
photo: Vec<TempFile<'r>>,
}
@ -64,18 +67,7 @@ impl TryFrom<TransactionRead> for Transaction {
},
};
let date = first_split.date;
let amount = t
.attributes
.transactions
.iter()
.filter_map(|t| match t.amount.parse::<f64>() {
Ok(v) => Some(v),
Err(e) => {
error!("Invalid amount: {}", e);
None
},
})
.sum();
let amount = t.amount();
let description = if let Some(title) = &t.attributes.group_title {
title.into()
} else {
@ -158,11 +150,72 @@ async fn get_transaction(id: &str, ctx: &State<Context>) -> Option<Template> {
#[rocket::post("/transactions/<id>", data = "<form>")]
async fn update_transaction(
id: f32,
id: &str,
form: Form<TransactionPostData<'_>>,
) -> (Status, &'static str) {
println!("{} {} {}", id, form.amount, form.photo.len());
(Status::ImATeapot, "")
ctx: &State<Context>,
) -> (Status, String) {
let txn = match ctx.firefly.get_transaction(id).await {
Ok(t) => t.data,
Err(e) => {
error!("Failed to get transaction {}: {}", id, e);
return (Status::NotFound, format!("Invalid transaction {}", id));
},
};
let amount = txn.amount();
if amount != form.amount && txn.attributes.transactions.len() != 1 {
return (
Status::BadRequest,
"Cannot update the amount of a split transaction".into(),
);
}
let mut splits: Vec<_> = txn
.attributes
.transactions
.into_iter()
.map(|s| TransactionSplitUpdate {
transaction_journal_id: s.transaction_journal_id,
amount: s.amount,
notes: s.notes,
})
.collect();
if let Some(split) = splits.last_mut() {
if form.amount != amount {
split.amount = form.amount.to_string();
}
if !form.notes.is_empty() {
split.notes = Some(form.notes.clone());
}
}
let update = TransactionUpdate {
transactions: splits,
};
if let Err(e) = ctx.firefly.update_transaction(id, &update).await {
error!("Failed to update transaction {}: {}", id, e);
return (Status::BadRequest, e.to_string());
}
for file in &form.photo[..] {
let stream = match file.open().await {
Ok(f) => f,
Err(e) => {
error!("Failed to open uploaded file: {}", e);
continue;
},
};
let mut reader = BufReader::new(stream);
let mut content = Vec::new();
if let Err(e) = reader.read_to_end(&mut content).await {
error!("Failed to read uploaded file: {}", e);
continue;
}
if let Err(e) = ctx
.firefly
.attach_receipt(id, content, file.name().unwrap_or("receipt.jpg"))
.await
{
error!("Failed to attach receipt to transaction: {}", e);
}
}
(Status::Ok, "Successfully updated transaction".into())
}
#[rocket::launch]

View File

@ -7,7 +7,7 @@
<h1>Update Transaction</h1>
<nav>
<sl-breadcrumb>
<sl-breadcrumb-item href="/">Transactions</sl-breadcrumb-item>
<sl-breadcrumb-item href="/transactions">Transactions</sl-breadcrumb-item>
<sl-breadcrumb-item>{{ description }}</sl-breadcrumb-item>
</sl-breadcrumb>
</nav>