Refactor backend
* Reorganizing code into more logical modules: - `routes` specifically for Rocket handler functions - `receipts` data model for receipts - `transactions` for Firefly transactions * Encapsulate database operations for receipts using the repository pattern; move SQL queries to external files (`sqlx` can only use string literals or external files for queries, not variables or constants) * Remove obsolete routes, templates for old transaction-focused pagesbugfix/ci-buildah
parent
16701a6313
commit
5c7225f077
|
@ -4,4 +4,5 @@
|
||||||
!js/
|
!js/
|
||||||
!migrations/
|
!migrations/
|
||||||
!src/
|
!src/
|
||||||
|
!sql/
|
||||||
!templates/
|
!templates/
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\nINSERT INTO receipts (\n vendor, date, amount, notes, filename, image\n) VALUES (\n$1, $2, $3, $4, $5, $6\n)\nRETURNING id\n",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Int4"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Varchar",
|
|
||||||
"Timestamptz",
|
|
||||||
"Numeric",
|
|
||||||
"Text",
|
|
||||||
"Varchar",
|
|
||||||
"Bytea"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "16d204cf990ebd5f78cfe21a5a9cbcfc9b0f084b755d0d9f065dbfe31c53679d"
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\nSELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nORDER BY date\n",
|
"query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nORDER BY date\n",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
@ -46,5 +46,5 @@
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "e62b8704ebb897be63463427ba9e808ddb9c7fb5d3326b388d5e7f1066732aa9"
|
"hash": "71dcdc6a24d99eff2dd7af673a0ebb6fda45b0ebd5244309472921a934e1b829"
|
||||||
}
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO receipts (\n vendor, date, amount, notes, filename, image\n) VALUES (\n $1, $2, $3, $4, $5, $6\n)\nRETURNING\n id, vendor, date, amount, notes, filename\n",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "vendor",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "date",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "amount",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "notes",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "filename",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Timestamptz",
|
||||||
|
"Numeric",
|
||||||
|
"Text",
|
||||||
|
"Varchar",
|
||||||
|
"Bytea"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a3fb24d192843a656565cebcc78a49f5cd14598e029d6bacfeb2326daf142119"
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\nSELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nWHERE\n id = $1\n",
|
"query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nWHERE\n id = $1\n",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
@ -48,5 +48,5 @@
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "9fe465619a9818b201fbf091018a75eea38e846468d1877062db8c5606550a13"
|
"hash": "e173b8a98dcdf9e4b259ae59e0ddcceef07851bbe9b429fd62b9d02bf01f52e1"
|
||||||
}
|
}
|
|
@ -14,6 +14,7 @@ COPY Cargo.* .
|
||||||
COPY src src
|
COPY src src
|
||||||
COPY .sqlx .sqlx
|
COPY .sqlx .sqlx
|
||||||
COPY migrations migrations
|
COPY migrations migrations
|
||||||
|
COPY sql sql
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cargo \
|
RUN --mount=type=cache,target=/root/.cargo \
|
||||||
cargo build --release --locked
|
cargo build --release --locked
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
SELECT
|
||||||
|
id, vendor, date, amount, notes, filename
|
||||||
|
FROM
|
||||||
|
receipts
|
||||||
|
WHERE
|
||||||
|
id = $1
|
|
@ -0,0 +1,7 @@
|
||||||
|
INSERT INTO receipts (
|
||||||
|
vendor, date, amount, notes, filename, image
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6
|
||||||
|
)
|
||||||
|
RETURNING
|
||||||
|
id, vendor, date, amount, notes, filename
|
|
@ -0,0 +1,5 @@
|
||||||
|
SELECT
|
||||||
|
id, vendor, date, amount, notes, filename
|
||||||
|
FROM
|
||||||
|
receipts
|
||||||
|
ORDER BY date
|
241
src/main.rs
241
src/main.rs
|
@ -1,25 +1,21 @@
|
||||||
mod config;
|
mod config;
|
||||||
mod firefly;
|
mod firefly;
|
||||||
mod receipts;
|
mod receipts;
|
||||||
|
mod routes;
|
||||||
|
mod transactions;
|
||||||
|
|
||||||
use chrono::{DateTime, FixedOffset};
|
|
||||||
use rocket::fairing::{self, AdHoc};
|
use rocket::fairing::{self, AdHoc};
|
||||||
use rocket::form::Form;
|
use rocket::fs::FileServer;
|
||||||
use rocket::fs::{FileServer, TempFile};
|
|
||||||
use rocket::http::Status;
|
|
||||||
use rocket::response::{Redirect, Responder};
|
use rocket::response::{Redirect, Responder};
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use rocket::tokio::io::{AsyncReadExt, BufReader};
|
use rocket::Rocket;
|
||||||
use rocket::{Rocket, State};
|
|
||||||
use rocket_db_pools::Database as RocketDatabase;
|
use rocket_db_pools::Database as RocketDatabase;
|
||||||
use rocket_dyn_templates::{context, Template};
|
use rocket_dyn_templates::Template;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use firefly::{
|
use firefly::Firefly;
|
||||||
Firefly, TransactionRead, TransactionSplitUpdate, TransactionUpdate,
|
|
||||||
};
|
|
||||||
|
|
||||||
struct Context {
|
struct Context {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
@ -46,21 +42,6 @@ impl Context {
|
||||||
#[database("receipts")]
|
#[database("receipts")]
|
||||||
struct Database(rocket_db_pools::sqlx::PgPool);
|
struct Database(rocket_db_pools::sqlx::PgPool);
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct Transaction {
|
|
||||||
pub id: String,
|
|
||||||
pub amount: f64,
|
|
||||||
pub description: String,
|
|
||||||
pub date: DateTime<FixedOffset>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(rocket::FromForm)]
|
|
||||||
struct TransactionPostData<'r> {
|
|
||||||
amount: f64,
|
|
||||||
notes: String,
|
|
||||||
photo: Vec<TempFile<'r>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ErrorBody {
|
struct ErrorBody {
|
||||||
error: String,
|
error: String,
|
||||||
|
@ -82,207 +63,11 @@ impl ErrorResponse {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/")]
|
#[rocket::get("/")]
|
||||||
async fn index() -> Redirect {
|
async fn index() -> Redirect {
|
||||||
Redirect::to("/receipts")
|
Redirect::to("/receipts")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::get("/transactions", format = "html", rank = 2)]
|
|
||||||
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", format = "json")]
|
|
||||||
async fn transaction_list_json(
|
|
||||||
ctx: &State<Context>,
|
|
||||||
) -> Result<Json<Vec<Transaction>>, ErrorResponse> {
|
|
||||||
let result = ctx
|
|
||||||
.firefly
|
|
||||||
.search_transactions(&ctx.config.firefly.search_query)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("Error fetching transaction list: {}", e);
|
|
||||||
ErrorResponse::new(
|
|
||||||
"Failed to fetch transaction list from Firefly III",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(
|
|
||||||
result
|
|
||||||
.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(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_migrations(rocket: Rocket<rocket::Build>) -> fairing::Result {
|
async fn run_migrations(rocket: Rocket<rocket::Build>) -> fairing::Result {
|
||||||
if let Some(db) = Database::fetch(&rocket) {
|
if let Some(db) = Database::fetch(&rocket) {
|
||||||
info!("Applying database migrations");
|
info!("Applying database migrations");
|
||||||
|
@ -320,17 +105,9 @@ async fn rocket() -> _ {
|
||||||
|
|
||||||
rocket
|
rocket
|
||||||
.manage(ctx)
|
.manage(ctx)
|
||||||
.mount(
|
.mount("/", rocket::routes![index])
|
||||||
"/",
|
.mount("/transactions", routes::transactions::routes())
|
||||||
rocket::routes![
|
.mount("/receipts", routes::receipts::routes())
|
||||||
index,
|
|
||||||
transaction_list,
|
|
||||||
transaction_list_json,
|
|
||||||
get_transaction,
|
|
||||||
update_transaction
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.mount("/receipts", receipts::routes())
|
|
||||||
.mount("/static", FileServer::from("static"))
|
.mount("/static", FileServer::from("static"))
|
||||||
.attach(Template::fairing())
|
.attach(Template::fairing())
|
||||||
.attach(Database::init())
|
.attach(Database::init())
|
||||||
|
|
227
src/receipts.rs
227
src/receipts.rs
|
@ -1,17 +1,12 @@
|
||||||
use chrono::{DateTime, FixedOffset};
|
use chrono::{DateTime, FixedOffset};
|
||||||
use rocket::form::Form;
|
|
||||||
use rocket::fs::TempFile;
|
use rocket::fs::TempFile;
|
||||||
use rocket::http::{ContentType, MediaType, Status};
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::tokio::io::{AsyncReadExt, BufReader};
|
use rocket::tokio::io::{AsyncReadExt, BufReader};
|
||||||
use rocket::Route;
|
use rocket_db_pools::Connection;
|
||||||
use rocket_db_pools::Connection as DatabaseConnection;
|
|
||||||
use rocket_dyn_templates::{context, Template};
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sqlx::types::Decimal;
|
use sqlx::types::Decimal;
|
||||||
use tracing::{error, info};
|
use tracing::error;
|
||||||
|
|
||||||
use super::Database;
|
use crate::Database;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct Receipt {
|
pub struct Receipt {
|
||||||
|
@ -43,7 +38,7 @@ pub struct ReceiptPostForm<'r> {
|
||||||
pub photo: TempFile<'r>,
|
pub photo: TempFile<'r>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ReceiptPostData {
|
pub struct ReceiptPostData {
|
||||||
pub date: DateTime<FixedOffset>,
|
pub date: DateTime<FixedOffset>,
|
||||||
pub vendor: String,
|
pub vendor: String,
|
||||||
pub amount: Decimal,
|
pub amount: Decimal,
|
||||||
|
@ -53,7 +48,7 @@ struct ReceiptPostData {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
enum ReceiptPostFormError {
|
pub enum ReceiptPostFormError {
|
||||||
#[error("Invalid date: {0}")]
|
#[error("Invalid date: {0}")]
|
||||||
Date(#[from] chrono::format::ParseError),
|
Date(#[from] chrono::format::ParseError),
|
||||||
#[error("Invalid amount: {0}")]
|
#[error("Invalid amount: {0}")]
|
||||||
|
@ -77,7 +72,7 @@ pub enum DeleteReceiptResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReceiptPostData {
|
impl ReceiptPostData {
|
||||||
async fn from_form(
|
pub async fn from_form(
|
||||||
form: &ReceiptPostForm<'_>,
|
form: &ReceiptPostForm<'_>,
|
||||||
) -> Result<Self, ReceiptPostFormError> {
|
) -> Result<Self, ReceiptPostFormError> {
|
||||||
let date = DateTime::parse_from_str(
|
let date = DateTime::parse_from_str(
|
||||||
|
@ -109,72 +104,29 @@ impl ReceiptPostData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::get("/")]
|
pub struct ReceiptsRepository {
|
||||||
pub async fn list_receipts(
|
conn: Connection<Database>,
|
||||||
mut db: DatabaseConnection<Database>,
|
}
|
||||||
) -> (Status, Template) {
|
impl ReceiptsRepository {
|
||||||
let result = rocket_db_pools::sqlx::query_as!(
|
pub fn new(conn: Connection<Database>) -> Self {
|
||||||
ReceiptJson,
|
Self { conn }
|
||||||
r##"
|
|
||||||
SELECT
|
|
||||||
id, vendor, date, amount, notes, filename
|
|
||||||
FROM
|
|
||||||
receipts
|
|
||||||
ORDER BY date
|
|
||||||
"##,
|
|
||||||
)
|
|
||||||
.fetch_all(&mut **db)
|
|
||||||
.await;
|
|
||||||
match result {
|
|
||||||
Ok(r) => (
|
|
||||||
Status::Ok,
|
|
||||||
Template::render(
|
|
||||||
"receipt-list",
|
|
||||||
context! {
|
|
||||||
receipts: r,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Err(e) => (
|
|
||||||
Status::InternalServerError,
|
|
||||||
Template::render(
|
|
||||||
"error",
|
|
||||||
context! {
|
|
||||||
error: e.to_string(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/add")]
|
pub async fn list_receipts(
|
||||||
pub async fn receipt_form() -> Template {
|
&mut self,
|
||||||
Template::render("receipt-form", context! {})
|
) -> Result<Vec<ReceiptJson>, sqlx::Error> {
|
||||||
}
|
sqlx::query_file_as!(ReceiptJson, "sql/receipts/list-receipts.sql")
|
||||||
|
.fetch_all(&mut **self.conn)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::post("/add", data = "<form>")]
|
pub async fn add_receipt(
|
||||||
pub async fn add_receipt(
|
&mut self,
|
||||||
form: Form<ReceiptPostForm<'_>>,
|
data: &ReceiptPostData,
|
||||||
mut db: DatabaseConnection<Database>,
|
) -> Result<ReceiptJson, sqlx::Error> {
|
||||||
) -> (Status, Json<AddReceiptResponse>) {
|
let result = sqlx::query_file_as!(
|
||||||
let data = match ReceiptPostData::from_form(&form).await {
|
ReceiptJson,
|
||||||
Ok(d) => d,
|
"sql/receipts/insert-receipt.sql",
|
||||||
Err(e) => {
|
|
||||||
return (
|
|
||||||
Status::BadRequest,
|
|
||||||
Json(AddReceiptResponse::Error(e.to_string())),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let result = rocket_db_pools::sqlx::query!(
|
|
||||||
r##"
|
|
||||||
INSERT INTO receipts (
|
|
||||||
vendor, date, amount, notes, filename, image
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6
|
|
||||||
)
|
|
||||||
RETURNING id
|
|
||||||
"##,
|
|
||||||
data.vendor,
|
data.vendor,
|
||||||
data.date,
|
data.date,
|
||||||
data.amount,
|
data.amount,
|
||||||
|
@ -182,120 +134,39 @@ RETURNING id
|
||||||
data.filename,
|
data.filename,
|
||||||
data.photo,
|
data.photo,
|
||||||
)
|
)
|
||||||
.fetch_one(&mut **db)
|
.fetch_one(&mut **self.conn)
|
||||||
.await;
|
.await?;
|
||||||
match result {
|
Ok(result)
|
||||||
Ok(r) => {
|
|
||||||
info!("Created new receipt {}", r.id);
|
|
||||||
(
|
|
||||||
Status::Ok,
|
|
||||||
Json(AddReceiptResponse::Success(ReceiptJson {
|
|
||||||
id: r.id,
|
|
||||||
vendor: data.vendor,
|
|
||||||
date: data.date,
|
|
||||||
amount: data.amount,
|
|
||||||
notes: Some(data.notes),
|
|
||||||
filename: data.filename,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to insert new receipt record: {}", e);
|
|
||||||
(
|
|
||||||
Status::InternalServerError,
|
|
||||||
Json(AddReceiptResponse::Error(e.to_string())),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/<id>")]
|
pub async fn get_receipt(
|
||||||
pub async fn get_receipt(
|
&mut self,
|
||||||
id: i32,
|
id: i32,
|
||||||
mut db: DatabaseConnection<Database>,
|
) -> Result<ReceiptJson, sqlx::Error> {
|
||||||
) -> Option<Template> {
|
sqlx::query_file_as!(ReceiptJson, "sql/receipts/get-receipt.sql", id,)
|
||||||
let result = rocket_db_pools::sqlx::query_as!(
|
.fetch_one(&mut **self.conn)
|
||||||
ReceiptJson,
|
.await
|
||||||
r##"
|
|
||||||
SELECT
|
|
||||||
id, vendor, date, amount, notes, filename
|
|
||||||
FROM
|
|
||||||
receipts
|
|
||||||
WHERE
|
|
||||||
id = $1
|
|
||||||
"##,
|
|
||||||
id,
|
|
||||||
)
|
|
||||||
.fetch_one(&mut **db)
|
|
||||||
.await;
|
|
||||||
match result {
|
|
||||||
Ok(r) => Some(Template::render("receipt", r)),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error fetching receipt image: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
pub async fn get_receipt_photo(
|
||||||
|
&mut self,
|
||||||
#[rocket::get("/<id>/view/<filename>")]
|
|
||||||
pub async fn view_receipt_photo(
|
|
||||||
id: i32,
|
id: i32,
|
||||||
#[allow(unused_variables)] filename: &str,
|
) -> Result<(String, Vec<u8>), sqlx::Error> {
|
||||||
mut db: DatabaseConnection<Database>,
|
let result = sqlx::query!(
|
||||||
) -> Option<(ContentType, Vec<u8>)> {
|
|
||||||
let result = rocket_db_pools::sqlx::query!(
|
|
||||||
"SELECT filename, image FROM receipts WHERE id = $1",
|
"SELECT filename, image FROM receipts WHERE id = $1",
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
.fetch_one(&mut **db)
|
.fetch_one(&mut **self.conn)
|
||||||
.await;
|
.await?;
|
||||||
match result {
|
Ok((result.filename, result.image))
|
||||||
Ok(r) => {
|
|
||||||
let mt = r
|
|
||||||
.filename
|
|
||||||
.rsplit_once('.')
|
|
||||||
.and_then(|(_, ext)| MediaType::from_extension(ext))
|
|
||||||
.unwrap_or(MediaType::Binary);
|
|
||||||
Some((ContentType(mt), r.image))
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error fetching receipt image: {}", e);
|
|
||||||
None
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::delete("/<id>")]
|
pub async fn delete_receipt(
|
||||||
pub async fn delete_receipt(
|
&mut self,
|
||||||
id: i32,
|
id: i32,
|
||||||
mut db: DatabaseConnection<Database>,
|
) -> Result<(), sqlx::Error> {
|
||||||
) -> (Status, Json<DeleteReceiptResponse>) {
|
sqlx::query_as!(ReceiptJson, "DELETE FROM receipts WHERE id = $1", id)
|
||||||
let result = rocket_db_pools::sqlx::query_as!(
|
.execute(&mut **self.conn)
|
||||||
ReceiptJson,
|
.await?;
|
||||||
"DELETE FROM receipts WHERE id = $1",
|
Ok(())
|
||||||
id,
|
|
||||||
)
|
|
||||||
.execute(&mut **db)
|
|
||||||
.await;
|
|
||||||
match result {
|
|
||||||
Ok(_) => (Status::Ok, Json(DeleteReceiptResponse::Success)),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error fetching receipt image: {}", e);
|
|
||||||
(
|
|
||||||
Status::InternalServerError,
|
|
||||||
Json(DeleteReceiptResponse::Error(e.to_string())),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
|
||||||
rocket::routes![
|
|
||||||
add_receipt,
|
|
||||||
delete_receipt,
|
|
||||||
get_receipt,
|
|
||||||
list_receipts,
|
|
||||||
receipt_form,
|
|
||||||
view_receipt_photo,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod receipts;
|
||||||
|
pub mod transactions;
|
|
@ -0,0 +1,137 @@
|
||||||
|
use rocket::form::Form;
|
||||||
|
use rocket::http::{ContentType, MediaType, Status};
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::Route;
|
||||||
|
use rocket_db_pools::Connection as DatabaseConnection;
|
||||||
|
use rocket_dyn_templates::{context, Template};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::receipts::*;
|
||||||
|
use crate::Database;
|
||||||
|
|
||||||
|
#[rocket::get("/")]
|
||||||
|
pub async fn list_receipts(
|
||||||
|
db: DatabaseConnection<Database>,
|
||||||
|
) -> (Status, Template) {
|
||||||
|
let mut repo = ReceiptsRepository::new(db);
|
||||||
|
match repo.list_receipts().await {
|
||||||
|
Ok(r) => (
|
||||||
|
Status::Ok,
|
||||||
|
Template::render(
|
||||||
|
"receipt-list",
|
||||||
|
context! {
|
||||||
|
receipts: r,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Err(e) => (
|
||||||
|
Status::InternalServerError,
|
||||||
|
Template::render(
|
||||||
|
"error",
|
||||||
|
context! {
|
||||||
|
error: e.to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::get("/add")]
|
||||||
|
pub async fn receipt_form() -> Template {
|
||||||
|
Template::render("receipt-form", context! {})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::post("/add", data = "<form>")]
|
||||||
|
pub async fn add_receipt(
|
||||||
|
form: Form<ReceiptPostForm<'_>>,
|
||||||
|
db: DatabaseConnection<Database>,
|
||||||
|
) -> (Status, Json<AddReceiptResponse>) {
|
||||||
|
let data = match ReceiptPostData::from_form(&form).await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
Status::BadRequest,
|
||||||
|
Json(AddReceiptResponse::Error(e.to_string())),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut repo = ReceiptsRepository::new(db);
|
||||||
|
match repo.add_receipt(&data).await {
|
||||||
|
Ok(r) => {
|
||||||
|
info!("Created new receipt {}", r.id);
|
||||||
|
(Status::Ok, Json(AddReceiptResponse::Success(r)))
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to insert new receipt record: {}", e);
|
||||||
|
(
|
||||||
|
Status::InternalServerError,
|
||||||
|
Json(AddReceiptResponse::Error(e.to_string())),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::get("/<id>")]
|
||||||
|
pub async fn get_receipt(
|
||||||
|
id: i32,
|
||||||
|
db: DatabaseConnection<Database>,
|
||||||
|
) -> Option<Template> {
|
||||||
|
let mut repo = ReceiptsRepository::new(db);
|
||||||
|
match repo.get_receipt(id).await {
|
||||||
|
Ok(r) => Some(Template::render("receipt", r)),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error fetching receipt image: {}", e);
|
||||||
|
None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::get("/<id>/view/<_>")]
|
||||||
|
pub async fn view_receipt_photo(
|
||||||
|
id: i32,
|
||||||
|
db: DatabaseConnection<Database>,
|
||||||
|
) -> Option<(ContentType, Vec<u8>)> {
|
||||||
|
let mut repo = ReceiptsRepository::new(db);
|
||||||
|
match repo.get_receipt_photo(id).await {
|
||||||
|
Ok((filename, image)) => {
|
||||||
|
let mt = filename
|
||||||
|
.rsplit_once('.')
|
||||||
|
.and_then(|(_, ext)| MediaType::from_extension(ext))
|
||||||
|
.unwrap_or(MediaType::Binary);
|
||||||
|
Some((ContentType(mt), image))
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error fetching receipt image: {}", e);
|
||||||
|
None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::delete("/<id>")]
|
||||||
|
pub async fn delete_receipt(
|
||||||
|
id: i32,
|
||||||
|
db: DatabaseConnection<Database>,
|
||||||
|
) -> (Status, Json<DeleteReceiptResponse>) {
|
||||||
|
let mut repo = ReceiptsRepository::new(db);
|
||||||
|
match repo.delete_receipt(id).await {
|
||||||
|
Ok(_) => (Status::Ok, Json(DeleteReceiptResponse::Success)),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error fetching receipt image: {}", e);
|
||||||
|
(
|
||||||
|
Status::InternalServerError,
|
||||||
|
Json(DeleteReceiptResponse::Error(e.to_string())),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Vec<Route> {
|
||||||
|
rocket::routes![
|
||||||
|
add_receipt,
|
||||||
|
delete_receipt,
|
||||||
|
get_receipt,
|
||||||
|
list_receipts,
|
||||||
|
receipt_form,
|
||||||
|
view_receipt_photo,
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::Route;
|
||||||
|
use rocket::State;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::transactions::*;
|
||||||
|
use crate::{Context, ErrorResponse};
|
||||||
|
|
||||||
|
#[rocket::get("/", format = "json")]
|
||||||
|
async fn transaction_list(
|
||||||
|
ctx: &State<Context>,
|
||||||
|
) -> Result<Json<Vec<Transaction>>, ErrorResponse> {
|
||||||
|
let result = ctx
|
||||||
|
.firefly
|
||||||
|
.search_transactions(&ctx.config.firefly.search_query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Error fetching transaction list: {}", e);
|
||||||
|
ErrorResponse::new(
|
||||||
|
"Failed to fetch transaction list from Firefly III",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
result
|
||||||
|
.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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Vec<Route> {
|
||||||
|
rocket::routes![transaction_list]
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
use chrono::{DateTime, FixedOffset};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::firefly::TransactionRead;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Transaction {
|
||||||
|
pub id: String,
|
||||||
|
pub amount: f64,
|
||||||
|
pub description: String,
|
||||||
|
pub date: DateTime<FixedOffset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue