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
parent
0c6f9385e6
commit
d2b93bff3b
101
src/firefly.rs
101
src/firefly.rs
|
@ -1,6 +1,7 @@
|
||||||
use reqwest::header::{HeaderMap, HeaderValue};
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
pub struct Firefly {
|
pub struct Firefly {
|
||||||
url: String,
|
url: String,
|
||||||
|
@ -9,9 +10,11 @@ pub struct Firefly {
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct TransactionSplit {
|
pub struct TransactionSplit {
|
||||||
|
pub transaction_journal_id: String,
|
||||||
pub date: chrono::DateTime<chrono::FixedOffset>,
|
pub date: chrono::DateTime<chrono::FixedOffset>,
|
||||||
pub amount: String,
|
pub amount: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
pub notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
@ -36,6 +39,57 @@ pub struct TransactionSingle {
|
||||||
pub data: TransactionRead,
|
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 {
|
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();
|
||||||
|
@ -83,4 +137,49 @@ impl Firefly {
|
||||||
res.error_for_status_ref()?;
|
res.error_for_status_ref()?;
|
||||||
res.json().await
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
89
src/main.rs
89
src/main.rs
|
@ -7,13 +7,16 @@ use rocket::form::Form;
|
||||||
use rocket::fs::{FileServer, TempFile};
|
use rocket::fs::{FileServer, TempFile};
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
use rocket::response::Redirect;
|
use rocket::response::Redirect;
|
||||||
|
use rocket::tokio::io::{AsyncReadExt, BufReader};
|
||||||
use rocket::State;
|
use rocket::State;
|
||||||
use rocket_dyn_templates::{context, Template};
|
use rocket_dyn_templates::{context, Template};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
use config::{Config, ConfigError};
|
use config::{Config, ConfigError};
|
||||||
use firefly::{Firefly, TransactionRead};
|
use firefly::{
|
||||||
|
Firefly, TransactionRead, TransactionSplitUpdate, TransactionUpdate,
|
||||||
|
};
|
||||||
|
|
||||||
struct Context {
|
struct Context {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
@ -47,7 +50,7 @@ pub struct Transaction {
|
||||||
|
|
||||||
#[derive(rocket::FromForm)]
|
#[derive(rocket::FromForm)]
|
||||||
struct TransactionPostData<'r> {
|
struct TransactionPostData<'r> {
|
||||||
amount: f32,
|
amount: f64,
|
||||||
notes: String,
|
notes: String,
|
||||||
photo: Vec<TempFile<'r>>,
|
photo: Vec<TempFile<'r>>,
|
||||||
}
|
}
|
||||||
|
@ -64,18 +67,7 @@ impl TryFrom<TransactionRead> for Transaction {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let date = first_split.date;
|
let date = first_split.date;
|
||||||
let amount = t
|
let amount = t.amount();
|
||||||
.attributes
|
|
||||||
.transactions
|
|
||||||
.iter()
|
|
||||||
.filter_map(|t| match t.amount.parse::<f64>() {
|
|
||||||
Ok(v) => Some(v),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Invalid amount: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.sum();
|
|
||||||
let description = if let Some(title) = &t.attributes.group_title {
|
let description = if let Some(title) = &t.attributes.group_title {
|
||||||
title.into()
|
title.into()
|
||||||
} else {
|
} else {
|
||||||
|
@ -158,11 +150,72 @@ async fn get_transaction(id: &str, ctx: &State<Context>) -> Option<Template> {
|
||||||
|
|
||||||
#[rocket::post("/transactions/<id>", data = "<form>")]
|
#[rocket::post("/transactions/<id>", data = "<form>")]
|
||||||
async fn update_transaction(
|
async fn update_transaction(
|
||||||
id: f32,
|
id: &str,
|
||||||
form: Form<TransactionPostData<'_>>,
|
form: Form<TransactionPostData<'_>>,
|
||||||
) -> (Status, &'static str) {
|
ctx: &State<Context>,
|
||||||
println!("{} {} {}", id, form.amount, form.photo.len());
|
) -> (Status, String) {
|
||||||
(Status::ImATeapot, "")
|
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]
|
#[rocket::launch]
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<h1>Update Transaction</h1>
|
<h1>Update Transaction</h1>
|
||||||
<nav>
|
<nav>
|
||||||
<sl-breadcrumb>
|
<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-item>{{ description }}</sl-breadcrumb-item>
|
||||||
</sl-breadcrumb>
|
</sl-breadcrumb>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
Loading…
Reference in New Issue