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 pages
bugfix/ci-buildah
Dustin 2025-03-11 20:05:41 -05:00
parent 16701a6313
commit 5c7225f077
15 changed files with 380 additions and 462 deletions

View File

@ -4,4 +4,5 @@
!js/ !js/
!migrations/ !migrations/
!src/ !src/
!sql/
!templates/ !templates/

View File

@ -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"
}

View File

@ -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"
} }

View File

@ -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"
}

View File

@ -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"
} }

View File

@ -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

View File

@ -0,0 +1,6 @@
SELECT
id, vendor, date, amount, notes, filename
FROM
receipts
WHERE
id = $1

View File

@ -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

View File

@ -0,0 +1,5 @@
SELECT
id, vendor, date, amount, notes, filename
FROM
receipts
ORDER BY date

View File

@ -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())

View File

@ -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,
]
}

2
src/routes/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod receipts;
pub mod transactions;

137
src/routes/receipts.rs Normal file
View File

@ -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,
]
}

View File

@ -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]
}

40
src/transactions.rs Normal file
View File

@ -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,
})
}
}