269 lines
7.5 KiB
Rust
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())
|
|
}
|