use chrono::{DateTime, FixedOffset}; use rocket::fs::TempFile; use rocket::tokio::io::{AsyncReadExt, BufReader}; use rocket_db_pools::Connection; use serde::Serialize; use sqlx::types::Decimal; use tracing::error; use crate::imaging; use crate::Database; #[derive(Debug, Serialize)] pub struct Receipt { pub id: i32, pub date: DateTime, pub vendor: String, pub amount: Decimal, pub notes: Option, pub filename: String, pub image: Vec, } #[derive(Debug, Serialize)] pub struct ReceiptJson { pub id: i32, pub date: DateTime, pub vendor: String, pub amount: Decimal, pub notes: Option, pub filename: String, } #[derive(rocket::FromForm)] pub struct ReceiptPostForm<'r> { pub transaction: Option, pub date: String, pub vendor: String, pub amount: String, pub is_restaurant: Option, pub notes: String, pub photo: TempFile<'r>, } pub struct ReceiptPostData { pub date: DateTime, pub vendor: String, pub amount: Decimal, pub is_restaurant: bool, pub notes: String, pub filename: String, pub photo: Vec, } #[derive(Debug, thiserror::Error)] pub enum ReceiptPostFormError { #[error("Invalid date: {0}")] Date(#[from] chrono::format::ParseError), #[error("Invalid amount: {0}")] Amount(#[from] rust_decimal::Error), #[error("Error reading photo: {0}")] Photo(#[from] std::io::Error), #[error("Unsupported image type")] UnsupportedImageFormat, } #[derive(Serialize)] #[serde(rename_all = "lowercase")] pub enum AddReceiptResponse { Success(ReceiptJson), Error(String), } #[derive(Serialize)] #[serde(rename_all = "lowercase")] pub enum DeleteReceiptResponse { Success, Error(String), } impl ReceiptPostData { pub async fn from_form( form: &ReceiptPostForm<'_>, ) -> Result { let date = DateTime::parse_from_str( &format!("{} 00:00:00 +0000", form.date), "%Y-%m-%d %H:%M:%S %z", )?; let vendor = form.vendor.clone(); use rust_decimal::prelude::FromStr; let amount = Decimal::from_str(&form.amount)?; let is_restaurant = form.is_restaurant.unwrap_or_default(); let notes = form.notes.clone(); let stream = form.photo.open().await?; let mut reader = BufReader::new(stream); let mut photo = Vec::new(); reader.read_to_end(&mut photo).await?; let extension = match imaging::get_type(&photo).as_deref() { Some("BMP") => "bmp", Some("JPEG") => "jpg", Some("PDF") => "pdf", Some("PNG") => "png", Some("PPM") => "ppm", Some("TIFF") => "tiff", Some("WEBP") => "webp", Some(f) => { error!("Unsupported image format: {}", f); return Err(ReceiptPostFormError::UnsupportedImageFormat); }, None => { return Err(ReceiptPostFormError::UnsupportedImageFormat); }, }; let filename = form .photo .name() .map(|n| format!("{}.{}", n, extension)) .unwrap_or("photo.jpg".into()); Ok(Self { date, vendor, amount, is_restaurant, notes, filename, photo, }) } } pub struct ReceiptsRepository { conn: Connection, } impl ReceiptsRepository { pub fn new(conn: Connection) -> Self { Self { conn } } pub async fn list_receipts( &mut self, ) -> Result, sqlx::Error> { sqlx::query_file_as!(ReceiptJson, "sql/receipts/list-receipts.sql") .fetch_all(&mut **self.conn) .await } pub async fn add_receipt( &mut self, data: &ReceiptPostData, ) -> Result { let result = sqlx::query_file_as!( ReceiptJson, "sql/receipts/insert-receipt.sql", data.vendor, data.date, data.amount, data.notes, data.filename, data.photo, ) .fetch_one(&mut **self.conn) .await?; Ok(result) } pub async fn get_receipt( &mut self, id: i32, ) -> Result { sqlx::query_file_as!(ReceiptJson, "sql/receipts/get-receipt.sql", id,) .fetch_one(&mut **self.conn) .await } pub async fn get_receipt_photo( &mut self, id: i32, ) -> Result<(String, Vec), sqlx::Error> { let result = sqlx::query!( "SELECT filename, image FROM receipts WHERE id = $1", id, ) .fetch_one(&mut **self.conn) .await?; Ok((result.filename, result.image)) } pub async fn delete_receipt( &mut self, id: i32, ) -> Result<(), sqlx::Error> { sqlx::query_as!(ReceiptJson, "DELETE FROM receipts WHERE id = $1", id) .execute(&mut **self.conn) .await?; Ok(()) } }