feat: add transaction attachments

This commit is contained in:
2026-04-04 08:54:06 +00:00
parent c26c89d922
commit 6c0214457e
9 changed files with 339 additions and 0 deletions

View File

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

View File

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

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

View File

@@ -1,4 +1,5 @@
pub mod accounts;
pub mod attachments;
pub mod exchange_rate;
pub mod goals;
pub mod reconciliations;

View File

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

View File

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

View 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 {}

View File

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

View File

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