Compare commits
5 Commits
aad5fd91a7
...
c5a3d1743f
| Author | SHA1 | Date | |
|---|---|---|---|
| c5a3d1743f | |||
| e8938146f3 | |||
| 6d4f587e14 | |||
| b199e17197 | |||
| 01ee259fe4 |
@@ -3,3 +3,4 @@ pub mod exchange_rate;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
pub mod transactions;
|
||||
pub mod transfers;
|
||||
|
||||
30
src-tauri/src/commands/transfers.rs
Normal file
30
src-tauri/src/commands/transfers.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use crate::errors::CommandResult;
|
||||
use crate::services::transfers::types::inputs::CreateTransferInput;
|
||||
use crate::services::transfers::types::outputs::TransferWithTransactions;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_transfer(
|
||||
state: tauri::State<'_, AppState>,
|
||||
input: CreateTransferInput,
|
||||
) -> CommandResult<TransferWithTransactions> {
|
||||
state.transfer_service().create_transfer(input, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transfers(
|
||||
state: tauri::State<'_, AppState>,
|
||||
account_id: Option<String>,
|
||||
start_date: Option<String>,
|
||||
end_date: Option<String>,
|
||||
) -> CommandResult<Vec<TransferWithTransactions>> {
|
||||
state
|
||||
.transfer_service()
|
||||
.get_transfers(account_id, start_date, end_date, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_transfer(state: tauri::State<'_, AppState>, id: String) -> CommandResult<()> {
|
||||
state.transfer_service().delete_transfer(id, None).await
|
||||
}
|
||||
@@ -85,6 +85,10 @@ pub fn run() {
|
||||
commands::transactions::bulk_create_transactions,
|
||||
commands::transactions::bulk_delete_transactions,
|
||||
commands::transactions::get_transaction_statistics,
|
||||
// Transfer commands
|
||||
commands::transfers::create_transfer,
|
||||
commands::transfers::get_transfers,
|
||||
commands::transfers::delete_transfer,
|
||||
// Exchange rate commands
|
||||
commands::exchange_rate::get_exchange_rate,
|
||||
commands::exchange_rate::get_supported_currencies,
|
||||
|
||||
@@ -7,10 +7,13 @@ use crate::errors::CommandResult;
|
||||
pub mod accounts;
|
||||
pub mod balance_calculator;
|
||||
pub mod exchange_rate;
|
||||
// pub mod goals;
|
||||
// pub mod reconciliations;
|
||||
// pub mod scheduled;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
pub mod transactions;
|
||||
pub mod transfers;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ServiceTrait: Send + Sync {
|
||||
@@ -28,6 +31,10 @@ pub struct ServiceFactoryResult {
|
||||
pub settings_service: Arc<dyn settings::service::SettingsService>,
|
||||
pub exchange_rate_service: Arc<dyn exchange_rate::service::ExchangeRateService>,
|
||||
pub tag_service: Arc<dyn tags::service::TagService>,
|
||||
pub transfer_service: Arc<dyn transfers::service::TransferService>,
|
||||
// pub scheduled_service: Arc<dyn scheduled::service::ScheduledTransactionService>,
|
||||
// pub goal_service: Arc<dyn goals::service::GoalService>,
|
||||
// pub reconciliation_service: Arc<dyn reconciliations::service::ReconciliationService>,
|
||||
}
|
||||
|
||||
impl ServiceFactory {
|
||||
@@ -47,6 +54,18 @@ impl ServiceFactory {
|
||||
db.clone(),
|
||||
tag_service.clone(),
|
||||
));
|
||||
let transfer_service = Arc::new(transfers::service::TransferServiceImpl::new(
|
||||
db.clone(),
|
||||
transaction_service.clone(),
|
||||
));
|
||||
// let scheduled_service = Arc::new(scheduled::service::ScheduledTransactionServiceImpl::new(
|
||||
// db.clone(),
|
||||
// tag_service.clone(),
|
||||
// ));
|
||||
// let goal_service = Arc::new(goals::service::GoalServiceImpl::new(db.clone()));
|
||||
// let reconciliation_service = Arc::new(
|
||||
// reconciliations::service::ReconciliationServiceImpl::new(db.clone()),
|
||||
// );
|
||||
|
||||
ServiceFactoryResult {
|
||||
account_service,
|
||||
@@ -54,6 +73,10 @@ impl ServiceFactory {
|
||||
exchange_rate_service,
|
||||
settings_service,
|
||||
tag_service,
|
||||
transfer_service,
|
||||
// scheduled_service,
|
||||
// goal_service,
|
||||
// reconciliation_service,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
src-tauri/src/services/transfers/mod.rs
Normal file
2
src-tauri/src/services/transfers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
804
src-tauri/src/services/transfers/service.rs
Normal file
804
src-tauri/src/services/transfers/service.rs
Normal file
@@ -0,0 +1,804 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
DatabaseConnection, DatabaseTransaction, Set, TransactionTrait, entity::*, query::*,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::connection::ConnectionSource;
|
||||
use crate::db::entities::{prelude::*, transactions, transfers};
|
||||
use crate::errors::CommandResult;
|
||||
use crate::services::ServiceTrait;
|
||||
use crate::services::balance_calculator::service::TransactionType;
|
||||
use crate::services::transactions::service::TransactionService;
|
||||
use crate::services::transactions::types::inputs::{
|
||||
CreateTransactionInput, UpdateTransactionInput,
|
||||
};
|
||||
use crate::services::transfers::types::inputs::CreateTransferInput;
|
||||
use crate::services::transfers::types::outputs::TransferWithTransactions;
|
||||
|
||||
pub type TransferModel = transfers::Model;
|
||||
pub type TransactionModel = transactions::Model;
|
||||
|
||||
enum TransactionRef<'a> {
|
||||
Existing(&'a DatabaseTransaction),
|
||||
New(DatabaseTransaction),
|
||||
}
|
||||
|
||||
impl<'a> TransactionRef<'a> {
|
||||
fn as_ref(&self) -> &DatabaseTransaction {
|
||||
match self {
|
||||
TransactionRef::Existing(t) => t,
|
||||
TransactionRef::New(t) => t,
|
||||
}
|
||||
}
|
||||
|
||||
async fn commit(self) -> CommandResult<()> {
|
||||
match self {
|
||||
TransactionRef::New(t) => {
|
||||
t.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
TransactionRef::Existing(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TransferService: ServiceTrait + Send + Sync {
|
||||
async fn create_transfer(
|
||||
&self,
|
||||
input: CreateTransferInput,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<TransferWithTransactions>;
|
||||
async fn get_transfers(
|
||||
&self,
|
||||
account_id: Option<String>,
|
||||
start_date: Option<String>,
|
||||
end_date: Option<String>,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<Vec<TransferWithTransactions>>;
|
||||
async fn delete_transfer(
|
||||
&self,
|
||||
id: String,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()>;
|
||||
}
|
||||
|
||||
pub struct TransferServiceImpl {
|
||||
db: DatabaseConnection,
|
||||
transaction_service: Arc<dyn TransactionService>,
|
||||
}
|
||||
|
||||
impl ServiceTrait for TransferServiceImpl {}
|
||||
|
||||
impl TransferServiceImpl {
|
||||
pub fn new(db: DatabaseConnection, transaction_service: Arc<dyn TransactionService>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
transaction_service,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_transaction_to_use<'a>(
|
||||
&'a self,
|
||||
tx: Option<&'a ConnectionSource<'a>>,
|
||||
) -> CommandResult<TransactionRef<'a>> {
|
||||
match tx {
|
||||
Some(ConnectionSource::Transaction(txn)) => Ok(TransactionRef::Existing(txn)),
|
||||
_ => {
|
||||
let txn = self.db.begin().await?;
|
||||
Ok(TransactionRef::New(txn))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TransferService for TransferServiceImpl {
|
||||
async fn create_transfer(
|
||||
&self,
|
||||
input: CreateTransferInput,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<TransferWithTransactions> {
|
||||
let txn_ref = self.get_transaction_to_use(tx).await?;
|
||||
let txn = txn_ref.as_ref();
|
||||
let conn_source = ConnectionSource::Transaction(txn);
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let transfer_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Get account currencies first
|
||||
let from_account = Accounts::find_by_id(&input.from_account_id)
|
||||
.one(txn)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::errors::AppError::NotFound(format!("From account {}", input.from_account_id))
|
||||
})?;
|
||||
|
||||
let to_account = Accounts::find_by_id(&input.to_account_id)
|
||||
.one(txn)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::errors::AppError::NotFound(format!("To account {}", input.to_account_id))
|
||||
})?;
|
||||
|
||||
// Create transfer out transaction using transaction service
|
||||
let from_txn_input = CreateTransactionInput {
|
||||
account_id: input.from_account_id.clone(),
|
||||
transaction_type: TransactionType::TransferOut,
|
||||
gross_amount: input.from_amount.clone(),
|
||||
tax_amount: Some("0.00000000".to_string()),
|
||||
net_amount: input.from_amount.clone(),
|
||||
currency: from_account.currency.clone(),
|
||||
description: input
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Transfer".to_string()),
|
||||
merchant: None,
|
||||
notes: None,
|
||||
receipt_paths: None,
|
||||
transaction_date: input.transfer_date.clone(),
|
||||
tag_ids: Vec::new(),
|
||||
};
|
||||
let from_txn_result = self
|
||||
.transaction_service
|
||||
.create_transaction(from_txn_input, Some(&conn_source))
|
||||
.await?;
|
||||
|
||||
// Create transfer in transaction using transaction service
|
||||
let to_txn_input = CreateTransactionInput {
|
||||
account_id: input.to_account_id.clone(),
|
||||
transaction_type: TransactionType::TransferIn,
|
||||
gross_amount: input.to_amount.clone(),
|
||||
tax_amount: Some("0.00000000".to_string()),
|
||||
net_amount: input.to_amount.clone(),
|
||||
currency: to_account.currency.clone(),
|
||||
description: input
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Transfer".to_string()),
|
||||
merchant: None,
|
||||
notes: None,
|
||||
receipt_paths: None,
|
||||
transaction_date: input.transfer_date.clone(),
|
||||
tag_ids: Vec::new(),
|
||||
};
|
||||
let to_txn_result = self
|
||||
.transaction_service
|
||||
.create_transaction(to_txn_input, Some(&conn_source))
|
||||
.await?;
|
||||
|
||||
// Update transactions with transfer linkage using transaction service
|
||||
// First update the from transaction with related_transaction_id
|
||||
let from_txn_update = UpdateTransactionInput {
|
||||
description: None,
|
||||
merchant: None,
|
||||
notes: None,
|
||||
gross_amount: None,
|
||||
tax_amount: None,
|
||||
net_amount: None,
|
||||
transaction_date: None,
|
||||
receipt_paths: None,
|
||||
tag_ids: None,
|
||||
};
|
||||
self.transaction_service
|
||||
.update_transaction(
|
||||
from_txn_result.transaction.id.clone(),
|
||||
from_txn_update,
|
||||
Some(&conn_source),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create the transfer record directly (transfer entity management stays in transfer service)
|
||||
let transfer_record = transfers::ActiveModel {
|
||||
id: Set(transfer_id.clone()),
|
||||
from_account_id: Set(input.from_account_id.clone()),
|
||||
to_account_id: Set(input.to_account_id.clone()),
|
||||
from_transaction_id: Set(Some(from_txn_result.transaction.id.clone())),
|
||||
to_transaction_id: Set(Some(to_txn_result.transaction.id.clone())),
|
||||
from_amount: Set(input.from_amount),
|
||||
to_amount: Set(input.to_amount),
|
||||
exchange_rate: Set(input.exchange_rate),
|
||||
exchange_rate_source: Set(input.exchange_rate_source),
|
||||
fees: Set(input.fees.unwrap_or("0.00000000".to_string())),
|
||||
description: Set(input.description),
|
||||
transfer_date: Set(input.transfer_date),
|
||||
created_at: Set(now),
|
||||
version: Set(1),
|
||||
device_id: Set(None),
|
||||
is_deleted: Set(false),
|
||||
};
|
||||
|
||||
let transfer_result = transfer_record.insert(txn).await?;
|
||||
|
||||
// Only commit if we created the transaction
|
||||
txn_ref.commit().await?;
|
||||
|
||||
Ok(TransferWithTransactions {
|
||||
transfer: transfer_result,
|
||||
from_transaction: Some(from_txn_result.transaction),
|
||||
to_transaction: Some(to_txn_result.transaction),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_transfers(
|
||||
&self,
|
||||
account_id: Option<String>,
|
||||
start_date: Option<String>,
|
||||
end_date: Option<String>,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<Vec<TransferWithTransactions>> {
|
||||
// Build the query
|
||||
let mut query = Transfers::find().filter(transfers::Column::IsDeleted.eq(false));
|
||||
|
||||
if let Some(account_id) = account_id {
|
||||
query = query.filter(
|
||||
transfers::Column::FromAccountId
|
||||
.eq(account_id.clone())
|
||||
.or(transfers::Column::ToAccountId.eq(account_id)),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(start_date) = start_date {
|
||||
query = query.filter(transfers::Column::TransferDate.gte(start_date));
|
||||
}
|
||||
|
||||
if let Some(end_date) = end_date {
|
||||
query = query.filter(transfers::Column::TransferDate.lte(end_date));
|
||||
}
|
||||
|
||||
query = query.order_by_desc(transfers::Column::TransferDate);
|
||||
|
||||
// Execute the query using the provided transaction or the database connection
|
||||
let transfers = query
|
||||
.all(&tx.unwrap_or(&ConnectionSource::Connection(&self.db)))
|
||||
.await?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for t in transfers {
|
||||
// Use transaction service to get transaction details
|
||||
let from_txn = if let Some(ref txn_id) = t.from_transaction_id {
|
||||
self.transaction_service
|
||||
.get_transaction(txn_id.clone(), tx)
|
||||
.await
|
||||
.ok()
|
||||
.map(|twt| twt.transaction)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let to_txn = if let Some(ref txn_id) = t.to_transaction_id {
|
||||
self.transaction_service
|
||||
.get_transaction(txn_id.clone(), tx)
|
||||
.await
|
||||
.ok()
|
||||
.map(|twt| twt.transaction)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
result.push(TransferWithTransactions {
|
||||
transfer: t,
|
||||
from_transaction: from_txn,
|
||||
to_transaction: to_txn,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn delete_transfer(
|
||||
&self,
|
||||
id: String,
|
||||
tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()> {
|
||||
let txn_ref = self.get_transaction_to_use(tx).await?;
|
||||
let txn = txn_ref.as_ref();
|
||||
|
||||
let transfer = Transfers::find_by_id(id.clone())
|
||||
.one(txn)
|
||||
.await?
|
||||
.ok_or_else(|| crate::errors::AppError::NotFound(format!("Transfer with id {}", id)))?;
|
||||
|
||||
// Soft delete associated transactions using transaction service
|
||||
if let Some(from_txn_id) = &transfer.from_transaction_id {
|
||||
self.transaction_service
|
||||
.delete_transaction(from_txn_id.clone(), tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(to_txn_id) = &transfer.to_transaction_id {
|
||||
self.transaction_service
|
||||
.delete_transaction(to_txn_id.clone(), tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Soft delete transfer
|
||||
let mut active_transfer: transfers::ActiveModel = transfer.into();
|
||||
active_transfer.is_deleted = Set(true);
|
||||
active_transfer.update(txn).await?;
|
||||
|
||||
// Only commit if we created the transaction
|
||||
txn_ref.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::entities::accounts;
|
||||
use crate::services::transactions::types::outputs::TransactionWithTags;
|
||||
use chrono::NaiveDateTime;
|
||||
use sea_orm::{DatabaseBackend, MockDatabase};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn create_mock_account(id: &str, name: &str, currency: &str, balance: &str) -> accounts::Model {
|
||||
accounts::Model {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
account_type: "checking".to_string(),
|
||||
currency: currency.to_string(),
|
||||
initial_balance: balance.to_string(),
|
||||
current_balance: balance.to_string(),
|
||||
color: Some("#3B82F6".to_string()),
|
||||
icon: Some("wallet".to_string()),
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
include_in_net_worth: true,
|
||||
show_in_combined_view: true,
|
||||
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("Failed to parse datetime"),
|
||||
updated_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("Failed to parse datetime"),
|
||||
version: 1,
|
||||
device_id: None,
|
||||
is_deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_mock_transaction(
|
||||
id: &str,
|
||||
account_id: &str,
|
||||
txn_type: &str,
|
||||
amount: &str,
|
||||
currency: &str,
|
||||
) -> transactions::Model {
|
||||
transactions::Model {
|
||||
id: id.to_string(),
|
||||
account_id: account_id.to_string(),
|
||||
transaction_type: txn_type.to_string(),
|
||||
gross_amount: amount.to_string(),
|
||||
tax_amount: "0.00000000".to_string(),
|
||||
net_amount: amount.to_string(),
|
||||
tax_rate: None,
|
||||
currency: currency.to_string(),
|
||||
description: "Test transfer".to_string(),
|
||||
merchant: None,
|
||||
notes: None,
|
||||
receipt_paths: None,
|
||||
receipt_ocr_data: None,
|
||||
transfer_id: None,
|
||||
related_transaction_id: None,
|
||||
schedule_id: None,
|
||||
is_scheduled_instance: false,
|
||||
is_auto_inserted: false,
|
||||
needs_review: false,
|
||||
transaction_date: "2024-01-15".to_string(),
|
||||
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("Failed to parse datetime"),
|
||||
updated_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("Failed to parse datetime"),
|
||||
version: 1,
|
||||
device_id: None,
|
||||
is_deleted: false,
|
||||
sync_status: "synced".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_mock_transfer(
|
||||
id: &str,
|
||||
from_account_id: &str,
|
||||
to_account_id: &str,
|
||||
from_amount: &str,
|
||||
to_amount: &str,
|
||||
) -> transfers::Model {
|
||||
transfers::Model {
|
||||
id: id.to_string(),
|
||||
from_account_id: from_account_id.to_string(),
|
||||
to_account_id: to_account_id.to_string(),
|
||||
from_transaction_id: Some(format!("txn-from-{}", id)),
|
||||
to_transaction_id: Some(format!("txn-to-{}", id)),
|
||||
from_amount: from_amount.to_string(),
|
||||
to_amount: to_amount.to_string(),
|
||||
exchange_rate: None,
|
||||
exchange_rate_source: None,
|
||||
fees: "0.00000000".to_string(),
|
||||
description: Some("Test transfer".to_string()),
|
||||
transfer_date: "2024-01-15".to_string(),
|
||||
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("Failed to parse datetime"),
|
||||
version: 1,
|
||||
device_id: None,
|
||||
is_deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_transfer_input() -> CreateTransferInput {
|
||||
CreateTransferInput {
|
||||
from_account_id: "acc-from-1".to_string(),
|
||||
to_account_id: "acc-to-1".to_string(),
|
||||
from_amount: "100.00000000".to_string(),
|
||||
to_amount: "100.00000000".to_string(),
|
||||
exchange_rate: None,
|
||||
exchange_rate_source: None,
|
||||
fees: None,
|
||||
description: Some("Test transfer".to_string()),
|
||||
transfer_date: "2024-01-15".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock implementation of TransactionService for testing
|
||||
struct MockTransactionService {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl ServiceTrait for MockTransactionService {}
|
||||
|
||||
#[async_trait]
|
||||
impl TransactionService for MockTransactionService {
|
||||
async fn create_transaction(
|
||||
&self,
|
||||
input: CreateTransactionInput,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
let txn = create_mock_transaction(
|
||||
&Uuid::new_v4().to_string(),
|
||||
&input.account_id,
|
||||
&input.transaction_type.to_string(),
|
||||
&input.net_amount,
|
||||
&input.currency,
|
||||
);
|
||||
Ok(TransactionWithTags {
|
||||
transaction: txn,
|
||||
tags: input.tag_ids,
|
||||
})
|
||||
}
|
||||
|
||||
async fn bulk_create_transactions(
|
||||
&self,
|
||||
_inputs: Vec<CreateTransactionInput>,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_transactions(
|
||||
&self,
|
||||
_filter: crate::services::transactions::types::inputs::TransactionFilter,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_transaction(
|
||||
&self,
|
||||
id: String,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
let txn = create_mock_transaction(&id, "acc-1", "transfer_out", "100.00", "USD");
|
||||
Ok(TransactionWithTags {
|
||||
transaction: txn,
|
||||
tags: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_transaction(
|
||||
&self,
|
||||
_id: String,
|
||||
_updates: crate::services::transactions::types::inputs::UpdateTransactionInput,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
let txn = create_mock_transaction("txn-1", "acc-1", "transfer_out", "100.00", "USD");
|
||||
Ok(TransactionWithTags {
|
||||
transaction: txn,
|
||||
tags: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_transaction(
|
||||
&self,
|
||||
_id: String,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn bulk_delete_transactions(
|
||||
&self,
|
||||
_ids: Vec<String>,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<crate::services::transactions::types::outputs::BulkDeleteResult>
|
||||
{
|
||||
Ok(
|
||||
crate::services::transactions::types::outputs::BulkDeleteResult {
|
||||
deleted_count: 0,
|
||||
failed_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn confirm_transaction(
|
||||
&self,
|
||||
_id: String,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_transactions_needing_review(
|
||||
&self,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_transaction_statistics(
|
||||
&self,
|
||||
_filter: crate::services::transactions::types::inputs::TransactionFilter,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<crate::services::transactions::types::inputs::TransactionStatistics>
|
||||
{
|
||||
Ok(
|
||||
crate::services::transactions::types::inputs::TransactionStatistics {
|
||||
total_income: "0".to_string(),
|
||||
total_expense: "0".to_string(),
|
||||
total_transfer_in: "0".to_string(),
|
||||
total_transfer_out: "0".to_string(),
|
||||
net_flow: "0".to_string(),
|
||||
count_income: 0,
|
||||
count_expense: 0,
|
||||
count_transfer: 0,
|
||||
total_count: 0,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_transactions_by_tags(
|
||||
&self,
|
||||
_tag_ids: Vec<String>,
|
||||
_match_all: bool,
|
||||
_tx: Option<&ConnectionSource<'_>>,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transfer_service_impl_new() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let transaction_service: Arc<dyn TransactionService> =
|
||||
Arc::new(MockTransactionService { db: db.clone() });
|
||||
let service = TransferServiceImpl::new(db, transaction_service);
|
||||
|
||||
// Just verify the service was created successfully
|
||||
// The service doesn't expose its fields, but we can verify it implements ServiceTrait
|
||||
assert!(service.on_app_start().await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_transfers_empty() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![] as Vec<transfers::Model>])
|
||||
.into_connection();
|
||||
|
||||
let transaction_service: Arc<dyn TransactionService> =
|
||||
Arc::new(MockTransactionService { db: db.clone() });
|
||||
let service = TransferServiceImpl::new(db, transaction_service);
|
||||
|
||||
let result = service.get_transfers(None, None, None, None).await;
|
||||
assert!(result.is_ok());
|
||||
let transfers = result.expect("Failed to get transfers");
|
||||
assert!(transfers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_transfers_with_account_filter() {
|
||||
let mock_transfer = create_mock_transfer("xfer-1", "acc-1", "acc-2", "100.00", "100.00");
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![mock_transfer]])
|
||||
.into_connection();
|
||||
|
||||
let transaction_service: Arc<dyn TransactionService> =
|
||||
Arc::new(MockTransactionService { db: db.clone() });
|
||||
let service = TransferServiceImpl::new(db, transaction_service);
|
||||
|
||||
let result = service
|
||||
.get_transfers(Some("acc-1".to_string()), None, None, None)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
let transfers = result.expect("Failed to get transfers");
|
||||
assert_eq!(transfers.len(), 1);
|
||||
assert_eq!(transfers[0].transfer.id, "xfer-1");
|
||||
assert_eq!(transfers[0].transfer.from_account_id, "acc-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_transfers_with_date_filter() {
|
||||
let mock_transfer = create_mock_transfer("xfer-1", "acc-1", "acc-2", "100.00", "100.00");
|
||||
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![mock_transfer]])
|
||||
.into_connection();
|
||||
|
||||
let transaction_service: Arc<dyn TransactionService> =
|
||||
Arc::new(MockTransactionService { db: db.clone() });
|
||||
let service = TransferServiceImpl::new(db, transaction_service);
|
||||
|
||||
let result = service
|
||||
.get_transfers(
|
||||
None,
|
||||
Some("2024-01-01".to_string()),
|
||||
Some("2024-01-31".to_string()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
let transfers = result.expect("Failed to get transfers");
|
||||
assert_eq!(transfers.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_transfer_not_found() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
.append_query_results(vec![vec![] as Vec<transfers::Model>])
|
||||
.into_connection();
|
||||
|
||||
let transaction_service: Arc<dyn TransactionService> =
|
||||
Arc::new(MockTransactionService { db: db.clone() });
|
||||
let service = TransferServiceImpl::new(db, transaction_service);
|
||||
|
||||
let result = service
|
||||
.delete_transfer("nonexistent".to_string(), None)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let err = result.expect_err("Expected error");
|
||||
assert!(err.to_string().contains("not found") || err.to_string().contains("Not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_transfer_input_validation() {
|
||||
// Test that CreateTransferInput is properly constructed
|
||||
let input = create_test_transfer_input();
|
||||
assert_eq!(input.from_account_id, "acc-from-1");
|
||||
assert_eq!(input.to_account_id, "acc-to-1");
|
||||
assert_eq!(input.from_amount, "100.00000000");
|
||||
assert_eq!(input.to_amount, "100.00000000");
|
||||
assert_eq!(input.transfer_date, "2024-01-15");
|
||||
assert_eq!(input.description, Some("Test transfer".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_transfer_different_currencies() {
|
||||
let input = CreateTransferInput {
|
||||
from_account_id: "acc-hkd".to_string(),
|
||||
to_account_id: "acc-usd".to_string(),
|
||||
from_amount: "1000.00000000".to_string(),
|
||||
to_amount: "128.00000000".to_string(),
|
||||
exchange_rate: Some("0.128".to_string()),
|
||||
exchange_rate_source: Some("manual".to_string()),
|
||||
fees: Some("10.00000000".to_string()),
|
||||
description: Some("HKD to USD transfer".to_string()),
|
||||
transfer_date: "2024-01-15".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(input.exchange_rate, Some("0.128".to_string()));
|
||||
assert_eq!(input.exchange_rate_source, Some("manual".to_string()));
|
||||
assert_eq!(input.fees, Some("10.00000000".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transfer_model_creation() {
|
||||
let transfer = create_mock_transfer("xfer-test", "acc-a", "acc-b", "500.00", "500.00");
|
||||
|
||||
assert_eq!(transfer.id, "xfer-test");
|
||||
assert_eq!(transfer.from_account_id, "acc-a");
|
||||
assert_eq!(transfer.to_account_id, "acc-b");
|
||||
assert_eq!(transfer.from_amount, "500.00");
|
||||
assert_eq!(transfer.to_amount, "500.00");
|
||||
assert!(!transfer.is_deleted);
|
||||
assert_eq!(transfer.version, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_transaction_service_create_transaction() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let service = MockTransactionService { db };
|
||||
|
||||
let input = CreateTransactionInput {
|
||||
account_id: "acc-1".to_string(),
|
||||
transaction_type: TransactionType::TransferOut,
|
||||
gross_amount: "100.00".to_string(),
|
||||
tax_amount: Some("0.00".to_string()),
|
||||
net_amount: "100.00".to_string(),
|
||||
currency: "USD".to_string(),
|
||||
description: "Test".to_string(),
|
||||
merchant: None,
|
||||
notes: None,
|
||||
receipt_paths: None,
|
||||
transaction_date: "2024-01-15".to_string(),
|
||||
tag_ids: vec!["tag-1".to_string()],
|
||||
};
|
||||
|
||||
let result = service.create_transaction(input, None).await;
|
||||
assert!(result.is_ok());
|
||||
let txn_with_tags = result.unwrap();
|
||||
assert_eq!(txn_with_tags.tags.len(), 1);
|
||||
assert_eq!(txn_with_tags.tags[0], "tag-1");
|
||||
assert_eq!(txn_with_tags.transaction.transaction_type, "transfer_out");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_transaction_service_delete_transaction() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let service = MockTransactionService { db };
|
||||
|
||||
let result = service
|
||||
.delete_transaction("txn-123".to_string(), None)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_service_trait_implementation() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite).into_connection();
|
||||
let transaction_service: Arc<dyn TransactionService> =
|
||||
Arc::new(MockTransactionService { db: db.clone() });
|
||||
let service = TransferServiceImpl::new(db, transaction_service);
|
||||
|
||||
// Test that ServiceTrait is properly implemented
|
||||
let result = service.on_app_start().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transfer_with_empty_description() {
|
||||
let input = CreateTransferInput {
|
||||
from_account_id: "acc-1".to_string(),
|
||||
to_account_id: "acc-2".to_string(),
|
||||
from_amount: "100.00".to_string(),
|
||||
to_amount: "100.00".to_string(),
|
||||
exchange_rate: None,
|
||||
exchange_rate_source: None,
|
||||
fees: None,
|
||||
description: None,
|
||||
transfer_date: "2024-01-15".to_string(),
|
||||
};
|
||||
|
||||
assert!(input.description.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transfer_with_zero_fees() {
|
||||
let input = CreateTransferInput {
|
||||
from_account_id: "acc-1".to_string(),
|
||||
to_account_id: "acc-2".to_string(),
|
||||
from_amount: "100.00".to_string(),
|
||||
to_amount: "100.00".to_string(),
|
||||
exchange_rate: None,
|
||||
exchange_rate_source: None,
|
||||
fees: Some("0.00000000".to_string()),
|
||||
description: Some("No fee transfer".to_string()),
|
||||
transfer_date: "2024-01-15".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(input.fees, Some("0.00000000".to_string()));
|
||||
}
|
||||
}
|
||||
14
src-tauri/src/services/transfers/types/inputs.rs
Normal file
14
src-tauri/src/services/transfers/types/inputs.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTransferInput {
|
||||
pub from_account_id: String,
|
||||
pub to_account_id: String,
|
||||
pub from_amount: String,
|
||||
pub to_amount: String,
|
||||
pub exchange_rate: Option<String>,
|
||||
pub exchange_rate_source: Option<String>,
|
||||
pub fees: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub transfer_date: String,
|
||||
}
|
||||
2
src-tauri/src/services/transfers/types/mod.rs
Normal file
2
src-tauri/src/services/transfers/types/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod inputs;
|
||||
pub mod outputs;
|
||||
10
src-tauri/src/services/transfers/types/outputs.rs
Normal file
10
src-tauri/src/services/transfers/types/outputs.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::entities::{transactions, transfers};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TransferWithTransactions {
|
||||
pub transfer: transfers::Model,
|
||||
pub from_transaction: Option<transactions::Model>,
|
||||
pub to_transaction: Option<transactions::Model>,
|
||||
}
|
||||
@@ -3,9 +3,17 @@ use std::sync::Arc;
|
||||
use crate::{
|
||||
db::service::DbService,
|
||||
services::{
|
||||
ServiceFactoryResult, ServiceTrait, accounts::service::AccountService,
|
||||
exchange_rate::service::ExchangeRateService, settings::service::SettingsService,
|
||||
tags::service::TagService, transactions::service::TransactionService,
|
||||
ServiceFactoryResult,
|
||||
ServiceTrait,
|
||||
accounts::service::AccountService,
|
||||
exchange_rate::service::ExchangeRateService,
|
||||
// goals::service::GoalService,
|
||||
// reconciliations::service::ReconciliationService,
|
||||
// scheduled::service::ScheduledTransactionService,
|
||||
settings::service::SettingsService,
|
||||
tags::service::TagService,
|
||||
transactions::service::TransactionService,
|
||||
transfers::service::TransferService,
|
||||
},
|
||||
};
|
||||
pub struct AppState {
|
||||
@@ -15,6 +23,10 @@ pub struct AppState {
|
||||
settings_service: Arc<dyn SettingsService>,
|
||||
exchange_rate_service: Arc<dyn ExchangeRateService>,
|
||||
tag_service: Arc<dyn TagService>,
|
||||
transfer_service: Arc<dyn TransferService>,
|
||||
// scheduled_service: Arc<dyn ScheduledTransactionService>,
|
||||
// goal_service: Arc<dyn GoalService>,
|
||||
// reconciliation_service: Arc<dyn ReconciliationService>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -27,6 +39,10 @@ impl AppState {
|
||||
settings_service: services.settings_service,
|
||||
exchange_rate_service: services.exchange_rate_service,
|
||||
tag_service: services.tag_service,
|
||||
transfer_service: services.transfer_service,
|
||||
// scheduled_service: services.scheduled_service,
|
||||
// goal_service: services.goal_service,
|
||||
// reconciliation_service: services.reconciliation_service,
|
||||
}
|
||||
}
|
||||
/// Get the database service
|
||||
@@ -59,6 +75,26 @@ impl AppState {
|
||||
&self.tag_service
|
||||
}
|
||||
|
||||
/// Get the transfer service
|
||||
pub fn transfer_service(&self) -> &Arc<dyn TransferService> {
|
||||
&self.transfer_service
|
||||
}
|
||||
|
||||
// /// Get the scheduled transaction service
|
||||
// pub fn scheduled_service(&self) -> &Arc<dyn ScheduledTransactionService> {
|
||||
// &self.scheduled_service
|
||||
// }
|
||||
|
||||
// /// Get the goal service
|
||||
// pub fn goal_service(&self) -> &Arc<dyn GoalService> {
|
||||
// &self.goal_service
|
||||
// }
|
||||
|
||||
// /// Get the reconciliation service
|
||||
// pub fn reconciliation_service(&self) -> &Arc<dyn ReconciliationService> {
|
||||
// &self.reconciliation_service
|
||||
// }
|
||||
|
||||
pub async fn on_app_start(&self) -> crate::errors::CommandResult<()> {
|
||||
// Call on_app_start for all services
|
||||
self.settings_service.on_app_start().await?;
|
||||
@@ -66,6 +102,10 @@ impl AppState {
|
||||
self.account_service.on_app_start().await?;
|
||||
self.transaction_service.on_app_start().await?;
|
||||
self.tag_service.on_app_start().await?;
|
||||
self.transfer_service.on_app_start().await?;
|
||||
// self.scheduled_service.on_app_start().await?;
|
||||
// self.goal_service.on_app_start().await?;
|
||||
// self.reconciliation_service.on_app_start().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user