feat: add transaction attachments
This commit is contained in:
@@ -12,6 +12,7 @@ mod m20250214_000009_create_scheduled_instances;
|
||||
mod m20250214_000010_add_reconciliations_indexes;
|
||||
mod m20250214_000011_add_reconciliation_tag;
|
||||
mod m20250214_000012_add_scheduled_indexes;
|
||||
mod m20250404_000013_create_transaction_attachments;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -31,6 +32,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20250214_000010_add_reconciliations_indexes::Migration),
|
||||
Box::new(m20250214_000011_add_reconciliation_tag::Migration),
|
||||
Box::new(m20250214_000012_add_scheduled_indexes::Migration),
|
||||
Box::new(m20250404_000013_create_transaction_attachments::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(TransactionAttachments::Table)
|
||||
.if_not_exists()
|
||||
.col(string(TransactionAttachments::Id).primary_key())
|
||||
.col(string(TransactionAttachments::TransactionId))
|
||||
.col(string(TransactionAttachments::RelativePath))
|
||||
.col(string_null(TransactionAttachments::MimeType))
|
||||
.col(integer(TransactionAttachments::ByteSize))
|
||||
.col(date_time(TransactionAttachments::CreatedAt))
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_transaction_attachments_transaction")
|
||||
.from(
|
||||
TransactionAttachments::Table,
|
||||
TransactionAttachments::TransactionId,
|
||||
)
|
||||
.to(Transactions::Table, Transactions::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(TransactionAttachments::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum TransactionAttachments {
|
||||
Table,
|
||||
Id,
|
||||
TransactionId,
|
||||
RelativePath,
|
||||
MimeType,
|
||||
ByteSize,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Transactions {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
222
src-tauri/src/commands/attachments.rs
Normal file
222
src-tauri/src/commands/attachments.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::entities::{prelude::*, transaction_attachments},
|
||||
errors::{AppError, CommandResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const MAX_ATTACHMENTS_PER_TXN: u64 = 5;
|
||||
const MAX_FILE_BYTES: usize = 15 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UploadTransactionAttachmentInput {
|
||||
pub transaction_id: String,
|
||||
pub file_bytes: Vec<u8>,
|
||||
pub file_name: String,
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TransactionAttachmentDto {
|
||||
pub id: String,
|
||||
pub transaction_id: String,
|
||||
/// Absolute path for `convertFileSrc` in the webview
|
||||
pub file_path: String,
|
||||
pub mime_type: Option<String>,
|
||||
pub byte_size: i32,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScanReceiptResult {
|
||||
pub suggested_amount: Option<String>,
|
||||
pub suggested_date: Option<String>,
|
||||
pub suggested_merchant: Option<String>,
|
||||
pub raw_text: Option<String>,
|
||||
pub confidence: Option<f64>,
|
||||
}
|
||||
|
||||
fn app_data_dir(app: &tauri::AppHandle) -> CommandResult<PathBuf> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e: tauri::Error| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
fn extension_from_name_or_mime(file_name: &str, mime_type: &Option<String>) -> String {
|
||||
Path::new(file_name)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.filter(|e| e.len() <= 10 && e.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||
.map(String::from)
|
||||
.or_else(|| {
|
||||
mime_type.as_ref().and_then(|m| {
|
||||
if m.contains("png") {
|
||||
Some("png".into())
|
||||
} else if m.contains("jpeg") || m.contains("jpg") {
|
||||
Some("jpg".into())
|
||||
} else if m.contains("webp") {
|
||||
Some("webp".into())
|
||||
} else if m.contains("gif") {
|
||||
Some("gif".into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "bin".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_transaction_attachment(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
input: UploadTransactionAttachmentInput,
|
||||
) -> CommandResult<TransactionAttachmentDto> {
|
||||
if input.file_bytes.is_empty() {
|
||||
return Err(AppError::Validation("Empty file".into()));
|
||||
}
|
||||
if input.file_bytes.len() > MAX_FILE_BYTES {
|
||||
return Err(AppError::Validation(format!(
|
||||
"File too large (max {} MB)",
|
||||
MAX_FILE_BYTES / (1024 * 1024)
|
||||
)));
|
||||
}
|
||||
|
||||
let conn = state.db().get_connection();
|
||||
|
||||
let txn = Transactions::find_by_id(&input.transaction_id)
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Transaction not found".into()))?;
|
||||
|
||||
if txn.is_deleted {
|
||||
return Err(AppError::Validation("Transaction is deleted".into()));
|
||||
}
|
||||
|
||||
let count = TransactionAttachments::find()
|
||||
.filter(transaction_attachments::Column::TransactionId.eq(&input.transaction_id))
|
||||
.count(conn)
|
||||
.await?;
|
||||
|
||||
if count >= MAX_ATTACHMENTS_PER_TXN {
|
||||
return Err(AppError::Validation(format!(
|
||||
"At most {MAX_ATTACHMENTS_PER_TXN} attachments per transaction"
|
||||
)));
|
||||
}
|
||||
|
||||
let base = app_data_dir(&app)?;
|
||||
let ext = extension_from_name_or_mime(&input.file_name, &input.mime_type);
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let rel = format!("receipts/{}/{}.{}", input.transaction_id, id, ext);
|
||||
let full = base.join(&rel);
|
||||
if let Some(parent) = full.parent() {
|
||||
fs::create_dir_all(parent).map_err(AppError::Io)?;
|
||||
}
|
||||
fs::write(&full, &input.file_bytes).map_err(AppError::Io)?;
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let byte_size_i32 = i32::try_from(input.file_bytes.len())
|
||||
.map_err(|_| AppError::Validation("File size overflow".into()))?;
|
||||
|
||||
let model = transaction_attachments::ActiveModel {
|
||||
id: Set(id.clone()),
|
||||
transaction_id: Set(input.transaction_id.clone()),
|
||||
relative_path: Set(rel),
|
||||
mime_type: Set(input.mime_type.clone()),
|
||||
byte_size: Set(byte_size_i32),
|
||||
created_at: Set(now),
|
||||
};
|
||||
|
||||
model.insert(conn).await?;
|
||||
|
||||
Ok(TransactionAttachmentDto {
|
||||
id,
|
||||
transaction_id: input.transaction_id,
|
||||
file_path: full.to_string_lossy().into_owned(),
|
||||
mime_type: input.mime_type,
|
||||
byte_size: byte_size_i32,
|
||||
created_at: DateTime::<Utc>::from_naive_utc_and_offset(now, Utc).to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_transaction_attachments(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
transaction_id: String,
|
||||
) -> CommandResult<Vec<TransactionAttachmentDto>> {
|
||||
let conn = state.db().get_connection();
|
||||
let base = app_data_dir(&app)?;
|
||||
|
||||
let rows = TransactionAttachments::find()
|
||||
.filter(transaction_attachments::Column::TransactionId.eq(transaction_id.clone()))
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let full = base.join(&row.relative_path);
|
||||
out.push(TransactionAttachmentDto {
|
||||
id: row.id,
|
||||
transaction_id: row.transaction_id,
|
||||
file_path: full.to_string_lossy().into_owned(),
|
||||
mime_type: row.mime_type,
|
||||
byte_size: row.byte_size,
|
||||
created_at: DateTime::<Utc>::from_naive_utc_and_offset(row.created_at, Utc).to_rfc3339(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_transaction_attachment(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
attachment_id: String,
|
||||
) -> CommandResult<()> {
|
||||
let conn = state.db().get_connection();
|
||||
let base = app_data_dir(&app)?;
|
||||
|
||||
let row = TransactionAttachments::find_by_id(&attachment_id)
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Attachment not found".into()))?;
|
||||
|
||||
let full = base.join(&row.relative_path);
|
||||
if full.exists() {
|
||||
let _ = fs::remove_file(&full);
|
||||
}
|
||||
|
||||
transaction_attachments::Entity::delete_by_id(attachment_id)
|
||||
.exec(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stub: returns empty suggestions until OCR is wired. Validates payload is non-empty.
|
||||
#[tauri::command]
|
||||
pub async fn scan_receipt(file_bytes: Vec<u8>) -> CommandResult<ScanReceiptResult> {
|
||||
if file_bytes.is_empty() {
|
||||
return Err(AppError::Validation("Empty file".into()));
|
||||
}
|
||||
if file_bytes.len() > MAX_FILE_BYTES {
|
||||
return Err(AppError::Validation("File too large".into()));
|
||||
}
|
||||
|
||||
Ok(ScanReceiptResult {
|
||||
suggested_amount: None,
|
||||
suggested_date: None,
|
||||
suggested_merchant: None,
|
||||
raw_text: None,
|
||||
confidence: None,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod accounts;
|
||||
pub mod attachments;
|
||||
pub mod exchange_rate;
|
||||
pub mod goals;
|
||||
pub mod reconciliations;
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod scheduled_instances;
|
||||
pub mod scheduled_transactions;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
pub mod transaction_attachments;
|
||||
pub mod transaction_tags;
|
||||
pub mod transactions;
|
||||
pub mod transfers;
|
||||
|
||||
@@ -12,6 +12,7 @@ pub use super::scheduled_instances::Entity as ScheduledInstances;
|
||||
pub use super::scheduled_transactions::Entity as ScheduledTransactions;
|
||||
pub use super::settings::Entity as Settings;
|
||||
pub use super::tags::Entity as Tags;
|
||||
pub use super::transaction_attachments::Entity as TransactionAttachments;
|
||||
pub use super::transaction_tags::Entity as TransactionTags;
|
||||
pub use super::transactions::Entity as Transactions;
|
||||
pub use super::transfers::Entity as Transfers;
|
||||
|
||||
38
src-tauri/src/db/entities/transaction_attachments.rs
Normal file
38
src-tauri/src/db/entities/transaction_attachments.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! Transaction receipt image attachments (files under app data dir).
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "transaction_attachments")]
|
||||
#[allow(dead_code)]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub transaction_id: String,
|
||||
/// Path relative to app data directory (e.g. receipts/{txn_id}/{uuid}.jpg)
|
||||
pub relative_path: String,
|
||||
pub mime_type: Option<String>,
|
||||
pub byte_size: i32,
|
||||
pub created_at: DateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::transactions::Entity",
|
||||
from = "Column::TransactionId",
|
||||
to = "super::transactions::Column::Id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Transactions,
|
||||
}
|
||||
|
||||
impl Related<super::transactions::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Transactions.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -60,6 +60,8 @@ pub enum Relation {
|
||||
ScheduledTransactions,
|
||||
#[sea_orm(has_many = "super::transaction_tags::Entity")]
|
||||
TransactionTags,
|
||||
#[sea_orm(has_many = "super::transaction_attachments::Entity")]
|
||||
TransactionAttachments,
|
||||
}
|
||||
|
||||
impl Related<super::accounts::Entity> for Entity {
|
||||
@@ -92,6 +94,12 @@ impl Related<super::transaction_tags::Entity> for Entity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::transaction_attachments::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::TransactionAttachments.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
super::transaction_tags::Relation::Tags.def()
|
||||
|
||||
@@ -85,6 +85,10 @@ pub fn run() {
|
||||
commands::transactions::bulk_create_transactions,
|
||||
commands::transactions::bulk_delete_transactions,
|
||||
commands::transactions::get_transaction_statistics,
|
||||
commands::attachments::upload_transaction_attachment,
|
||||
commands::attachments::list_transaction_attachments,
|
||||
commands::attachments::delete_transaction_attachment,
|
||||
commands::attachments::scan_receipt,
|
||||
// Transfer commands
|
||||
commands::transfers::create_transfer,
|
||||
commands::transfers::get_transfers,
|
||||
|
||||
Reference in New Issue
Block a user