receipts/src/main.rs

269 lines
7.5 KiB
Rust

mod config;
mod firefly;
mod receipts;
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_db_pools::Database as RocketDatabase;
use rocket_dyn_templates::{context, Template};
use serde::Serialize;
use tracing::{debug, error};
use config::Config;
use firefly::{
Firefly, TransactionRead, TransactionSplitUpdate, TransactionUpdate,
};
struct Context {
#[allow(dead_code)]
config: Config,
firefly: Firefly,
}
#[derive(Debug, thiserror::Error)]
enum InitError {
#[error("Invalid config: {0}")]
Config(std::io::Error),
}
impl Context {
pub fn init(config: Config) -> Result<Self, InitError> {
let token = std::fs::read_to_string(&config.firefly.token)
.map_err(InitError::Config)?;
let firefly = Firefly::new(&config.firefly.url, &token);
Ok(Self { config, firefly })
}
}
#[derive(RocketDatabase)]
#[database("receipts")]
struct Database(rocket_db_pools::sqlx::PgPool);
#[derive(Serialize)]
pub struct Transaction {
pub id: String,
pub amount: f64,
pub description: String,
pub date: String,
}
#[derive(rocket::FromForm)]
struct TransactionPostData<'r> {
amount: f64,
notes: String,
photo: Vec<TempFile<'r>>,
}
impl TryFrom<TransactionRead> for Transaction {
type Error = &'static str;
fn try_from(t: TransactionRead) -> Result<Self, Self::Error> {
let first_split = match t.attributes.transactions.first() {
Some(t) => t,
None => {
error!("Invalid transaction {}: no splits", t.id);
return Err("Transaction has no splits");
},
};
let date = first_split.date;
let amount = t.amount();
let description = if let Some(title) = &t.attributes.group_title {
title.into()
} else {
first_split.description.clone()
};
Ok(Self {
id: t.id,
amount,
description,
date: date.format("%A, %_d %B %Y").to_string(),
})
}
}
#[rocket::get("/")]
async fn index() -> Redirect {
Redirect::to(rocket::uri!(transaction_list()))
}
#[rocket::get("/transactions")]
async fn transaction_list(ctx: &State<Context>) -> (Status, Template) {
let result = ctx
.firefly
.search_transactions(&ctx.config.firefly.search_query)
.await;
match result {
Ok(r) => {
let transactions: Vec<_> = r
.data
.into_iter()
.filter_map(|t| match Transaction::try_from(t) {
Ok(t) => Some(t),
Err(e) => {
error!("Error parsing transaction details: {}", e);
None
},
})
.collect();
(
Status::Ok,
Template::render(
"transaction-list",
context! {
transactions: transactions,
},
),
)
},
Err(e) => {
error!("Error fetching transaction list: {}", e);
(
Status::InternalServerError,
Template::render(
"error",
context! {
error: "Failed to fetch transaction list from Firefly III",
},
),
)
},
}
}
#[rocket::get("/transactions/<id>")]
async fn get_transaction(id: &str, ctx: &State<Context>) -> Option<Template> {
match ctx.firefly.get_transaction(id).await {
Ok(t) => match Transaction::try_from(t.data) {
Ok(t) => return Some(Template::render("transaction", t)),
Err(e) => {
error!("Invalid transaction {}: {}", id, e);
},
},
Err(e) => {
error!("Failed to get transaction {}: {}", id, e);
},
}
None
}
#[rocket::post("/transactions/<id>", data = "<form>")]
async fn update_transaction(
id: &str,
form: Form<TransactionPostData<'_>>,
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();
let jrnl_id = 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());
}
split.transaction_journal_id.clone()
} else {
error!("Somehow, transaction {} has no splits!", id);
return (Status::BadRequest, "Invalid transaction: no splits".into());
};
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(
&jrnl_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]
async fn rocket() -> _ {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let rocket = rocket::build();
let figment = rocket.figment();
let config: Config = figment.extract().unwrap();
let ctx = match Context::init(config) {
Ok(c) => c,
Err(e) => {
error!("Failed to initialize application context: {}", e);
std::process::exit(1);
},
};
debug!("Using Firefly III URL {}", &ctx.firefly.url());
rocket
.manage(ctx)
.mount(
"/",
rocket::routes![
index,
transaction_list,
get_transaction,
update_transaction
],
)
.mount("/receipts", receipts::routes())
.mount("/static", FileServer::from("static"))
.attach(Template::fairing())
.attach(Database::init())
}