Compare commits
4 Commits
4e4a656c88
...
d60a573c64
| Author | SHA1 | Date | |
|---|---|---|---|
| d60a573c64 | |||
| e731c45a71 | |||
| bfbb771cbf | |||
| 30eb0b71cc |
@@ -1,3 +1,4 @@
|
||||
pub mod accounts;
|
||||
pub mod exchange_rate;
|
||||
pub mod settings;
|
||||
pub mod transactions;
|
||||
|
||||
66
src-tauri/src/commands/transactions.rs
Normal file
66
src-tauri/src/commands/transactions.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::errors::CommandResult;
|
||||
use crate::services::transactions::types::inputs::{CreateTransactionInput, TransactionFilter, UpdateTransactionInput};
|
||||
use crate::services::transactions::types::outputs::TransactionWithTags;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
input: CreateTransactionInput,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
state.transaction_service().create_transaction(input, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transactions(
|
||||
state: tauri::State<'_, AppState>,
|
||||
filter: TransactionFilter,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
state.transaction_service().get_transactions(filter, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
state.transaction_service().get_transaction(id, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
updates: UpdateTransactionInput,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
state
|
||||
.transaction_service()
|
||||
.update_transaction(id, updates, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<()> {
|
||||
state.transaction_service().delete_transaction(id, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transactions_needing_review(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
state
|
||||
.transaction_service()
|
||||
.get_transactions_needing_review(None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn confirm_transaction(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<()> {
|
||||
state.transaction_service().confirm_transaction(id, None).await
|
||||
}
|
||||
@@ -74,6 +74,14 @@ pub fn run() {
|
||||
commands::accounts::delete_account,
|
||||
commands::accounts::get_account_balance,
|
||||
commands::accounts::recalculate_account_balance,
|
||||
// Transaction commands
|
||||
commands::transactions::create_transaction,
|
||||
commands::transactions::get_transactions,
|
||||
commands::transactions::get_transaction,
|
||||
commands::transactions::update_transaction,
|
||||
commands::transactions::delete_transaction,
|
||||
commands::transactions::get_transactions_needing_review,
|
||||
commands::transactions::confirm_transaction,
|
||||
// Exchange rate commands
|
||||
commands::exchange_rate::get_exchange_rate,
|
||||
commands::exchange_rate::get_supported_currencies,
|
||||
|
||||
@@ -2,7 +2,6 @@ use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use rust_decimal::Decimal;
|
||||
use sea_orm::{DatabaseConnection, Set, entity::*, query::*};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -17,40 +16,12 @@ use crate::{
|
||||
inputs::{AccountFilter, CreateAccountInput, UpdateAccountInput},
|
||||
outputs::AccountBalance,
|
||||
},
|
||||
balance_calculator::service::{BalanceCalculator, BalanceCalculatorImpl},
|
||||
},
|
||||
};
|
||||
|
||||
pub type AccountModel = accounts::Model;
|
||||
|
||||
/// Check if account type is a liability (credit card, loan)
|
||||
fn is_liability_account(account_type: &str) -> bool {
|
||||
matches!(account_type.to_lowercase().as_str(), "credit_card" | "loan")
|
||||
}
|
||||
|
||||
/// Apply transaction amount to balance based on account type
|
||||
fn apply_transaction_to_balance(
|
||||
balance: &mut Decimal,
|
||||
amount: Decimal,
|
||||
txn_type: &str,
|
||||
is_liability: bool,
|
||||
) {
|
||||
if is_liability {
|
||||
// For liability accounts: expenses increase debt, payments decrease debt
|
||||
match txn_type {
|
||||
"expense" | "transfer_out" => *balance += amount,
|
||||
"income" | "transfer_in" => *balance -= amount,
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
// For asset accounts: normal balance calculation
|
||||
match txn_type {
|
||||
"expense" | "transfer_out" => *balance -= amount,
|
||||
"income" | "transfer_in" => *balance += amount,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AccountService: ServiceTrait + Send + Sync {
|
||||
async fn create_account(
|
||||
@@ -468,36 +439,14 @@ impl AccountService for AccountServiceImpl {
|
||||
});
|
||||
};
|
||||
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if !regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$")
|
||||
.map_err(|_| AppError::Validation("Invalid regex pattern".to_string()))?
|
||||
.is_match(&as_of_date)
|
||||
{
|
||||
return Err(AppError::Validation(
|
||||
"Invalid date format. Expected YYYY-MM-DD".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Calculate historical balance with liability account support
|
||||
let initial_balance = Decimal::from_str(&account.initial_balance)?;
|
||||
let is_liability = is_liability_account(&account.account_type);
|
||||
|
||||
let txns = Transactions::find()
|
||||
.filter(transactions::Column::AccountId.eq(&id))
|
||||
.filter(transactions::Column::IsDeleted.eq(false))
|
||||
.filter(transactions::Column::TransactionDate.lte(as_of_date))
|
||||
.order_by_asc(transactions::Column::TransactionDate)
|
||||
.all(conn)
|
||||
// Use BalanceCalculator for historical balance calculation
|
||||
let calculator = BalanceCalculatorImpl::new();
|
||||
let result = calculator
|
||||
.calculate_historical_balance(&id, &as_of_date, conn)
|
||||
.await?;
|
||||
|
||||
let mut balance = initial_balance;
|
||||
for txn in txns {
|
||||
let amount = Decimal::from_str(&txn.net_amount)?;
|
||||
apply_transaction_to_balance(&mut balance, amount, &txn.transaction_type, is_liability);
|
||||
}
|
||||
|
||||
Ok(AccountBalance {
|
||||
amount: balance.to_string(),
|
||||
amount: result.balance.to_string(),
|
||||
currency: account.currency,
|
||||
})
|
||||
}
|
||||
@@ -517,30 +466,17 @@ impl AccountService for AccountServiceImpl {
|
||||
crate::errors::AppError::NotFound(format!("Account with id {}", account_id))
|
||||
})?;
|
||||
|
||||
let mut balance = Decimal::from_str(&account.initial_balance)?;
|
||||
let is_liability = is_liability_account(&account.account_type);
|
||||
|
||||
// Query transactions directly for balance recalculation
|
||||
// TODO: use transcation service
|
||||
let transactions = Transactions::find()
|
||||
.filter(transactions::Column::AccountId.eq(account_id))
|
||||
.filter(transactions::Column::IsDeleted.eq(false))
|
||||
.order_by_asc(transactions::Column::TransactionDate)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
for txn in transactions {
|
||||
let amount = Decimal::from_str(&txn.net_amount)?;
|
||||
apply_transaction_to_balance(&mut balance, amount, &txn.transaction_type, is_liability);
|
||||
}
|
||||
// Use BalanceCalculator for balance recalculation
|
||||
let calculator = BalanceCalculatorImpl::new();
|
||||
let result = calculator.calculate_balance(account_id, conn).await?;
|
||||
|
||||
let mut active_account: accounts::ActiveModel = account.into();
|
||||
active_account.current_balance = Set(balance.to_string());
|
||||
active_account.current_balance = Set(result.balance.to_string());
|
||||
active_account.updated_at = Set(Utc::now().naive_utc());
|
||||
// Note: Not incrementing version since this is derived data
|
||||
|
||||
active_account.update(conn).await?;
|
||||
Ok(balance)
|
||||
Ok(result.balance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,12 +994,46 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_recalculate_balance_with_income() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// 1. recalculate_balance - find account
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_account("acc-1", "Test Account", "0.00")] as Vec<accounts::Model>,
|
||||
])
|
||||
.append_query_results(vec![vec![create_mock_transaction(
|
||||
"txn-1", "acc-1", "income", "500.00",
|
||||
)] as Vec<transactions::Model>])
|
||||
// 2. BalanceCalculator::calculate_balance - find account
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_account("acc-1", "Test Account", "0.00")] as Vec<accounts::Model>,
|
||||
])
|
||||
// 3. BalanceCalculator::calculate_balance - find transactions
|
||||
.append_query_results(vec![vec![transactions::Model {
|
||||
id: "txn-1".to_string(),
|
||||
account_id: "acc-1".to_string(),
|
||||
transaction_type: "income".to_string(),
|
||||
gross_amount: "500.00".to_string(),
|
||||
tax_amount: "0.00".to_string(),
|
||||
net_amount: "500.00".to_string(),
|
||||
tax_rate: None,
|
||||
currency: "USD".to_string(),
|
||||
description: "Test income".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-01".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(),
|
||||
}] as Vec<transactions::Model>])
|
||||
// 4. recalculate_balance - find account for update
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
@@ -1105,12 +1075,46 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_recalculate_balance_with_expense() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// 1. recalculate_balance - find account
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_account("acc-1", "Test Account", "0.00")] as Vec<accounts::Model>,
|
||||
])
|
||||
.append_query_results(vec![vec![create_mock_transaction(
|
||||
"txn-1", "acc-1", "expense", "200.00",
|
||||
)] as Vec<transactions::Model>])
|
||||
// 2. BalanceCalculator::calculate_balance - find account
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_account("acc-1", "Test Account", "0.00")] as Vec<accounts::Model>,
|
||||
])
|
||||
// 3. BalanceCalculator::calculate_balance - find transactions
|
||||
.append_query_results(vec![vec![transactions::Model {
|
||||
id: "txn-1".to_string(),
|
||||
account_id: "acc-1".to_string(),
|
||||
transaction_type: "expense".to_string(),
|
||||
gross_amount: "200.00".to_string(),
|
||||
tax_amount: "0.00".to_string(),
|
||||
net_amount: "200.00".to_string(),
|
||||
tax_rate: None,
|
||||
currency: "USD".to_string(),
|
||||
description: "Test expense".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-01".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(),
|
||||
}] as Vec<transactions::Model>])
|
||||
// 4. recalculate_balance - find account for update
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
@@ -1152,6 +1156,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_recalculate_balance_with_multiple_transactions() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// 1. recalculate_balance - find account
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
@@ -1180,12 +1185,43 @@ mod tests {
|
||||
device_id: None,
|
||||
is_deleted: false,
|
||||
}] as Vec<accounts::Model>])
|
||||
// 2. BalanceCalculator::calculate_balance - find account
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
account_type: "checking".to_string(),
|
||||
currency: "USD".to_string(),
|
||||
initial_balance: "1000.00".to_string(),
|
||||
current_balance: "0.00".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,
|
||||
}] as Vec<accounts::Model>])
|
||||
// 3. BalanceCalculator::calculate_balance - find transactions
|
||||
.append_query_results(vec![vec![
|
||||
create_mock_transaction("txn-1", "acc-1", "income", "500.00"),
|
||||
create_mock_transaction("txn-2", "acc-1", "expense", "200.00"),
|
||||
create_mock_transaction("txn-3", "acc-1", "transfer_out", "100.00"),
|
||||
create_mock_transaction("txn-4", "acc-1", "transfer_in", "50.00"),
|
||||
] as Vec<transactions::Model>])
|
||||
// 4. recalculate_balance - find account for update
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
@@ -1240,6 +1276,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_recalculate_balance_with_no_transactions() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// 1. recalculate_balance - find account
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
@@ -1268,7 +1305,39 @@ mod tests {
|
||||
device_id: None,
|
||||
is_deleted: false,
|
||||
}] as Vec<accounts::Model>])
|
||||
|
||||
// 2. BalanceCalculator::calculate_balance - find account
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
account_type: "checking".to_string(),
|
||||
currency: "USD".to_string(),
|
||||
initial_balance: "500.00".to_string(),
|
||||
current_balance: "0.00".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,
|
||||
}] as Vec<accounts::Model>])
|
||||
// 3. BalanceCalculator::calculate_balance - find transactions (empty)
|
||||
.append_query_results(vec![vec![] as Vec<transactions::Model>])
|
||||
// 4. recalculate_balance - find account for update
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
@@ -1364,10 +1433,17 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_get_balance_historical_success() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// 1. get_balance - find account
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_account("acc-1", "Test Account", "1500.50")]
|
||||
as Vec<accounts::Model>,
|
||||
])
|
||||
// 2. calculate_historical_balance - find account
|
||||
.append_query_results(vec![
|
||||
vec![create_mock_account("acc-1", "Test Account", "0.00")]
|
||||
as Vec<accounts::Model>,
|
||||
])
|
||||
// 3. calculate_historical_balance - find transactions
|
||||
.append_query_results(vec![vec![
|
||||
create_mock_transaction("txn-1", "acc-1", "income", "100.00"),
|
||||
create_mock_transaction("txn-2", "acc-1", "expense", "50.00"),
|
||||
@@ -1389,6 +1465,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_get_balance_historical_no_transactions() {
|
||||
let db = MockDatabase::new(DatabaseBackend::Sqlite)
|
||||
// 1. get_balance - find account
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
@@ -1417,6 +1494,36 @@ mod tests {
|
||||
device_id: None,
|
||||
is_deleted: false,
|
||||
}] as Vec<accounts::Model>])
|
||||
// 2. calculate_historical_balance - find account
|
||||
.append_query_results(vec![vec![accounts::Model {
|
||||
id: "acc-1".to_string(),
|
||||
name: "Test Account".to_string(),
|
||||
account_type: "checking".to_string(),
|
||||
currency: "USD".to_string(),
|
||||
initial_balance: "1000.00".to_string(),
|
||||
current_balance: "1500.00".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,
|
||||
}] as Vec<accounts::Model>])
|
||||
// 3. calculate_historical_balance - find transactions
|
||||
.append_query_results(vec![vec![] as Vec<transactions::Model>])
|
||||
.into_connection();
|
||||
|
||||
|
||||
1
src-tauri/src/services/balance_calculator/mod.rs
Normal file
1
src-tauri/src/services/balance_calculator/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod service;
|
||||
434
src-tauri/src/services/balance_calculator/service.rs
Normal file
434
src-tauri/src/services/balance_calculator/service.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use sea_orm::{entity::*, query::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::{
|
||||
connection::ConnectionSource,
|
||||
entities::{accounts, prelude::*, transactions},
|
||||
};
|
||||
use crate::errors::{AppError, CommandResult};
|
||||
|
||||
/// Transaction types supported by the system
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransactionType {
|
||||
/// Money entering an account (e.g., salary, refunds)
|
||||
Income,
|
||||
/// Money leaving an account (e.g., purchases, bills)
|
||||
Expense,
|
||||
/// Money transferred into this account from another account
|
||||
TransferIn,
|
||||
/// Money transferred out of this account to another account
|
||||
TransferOut,
|
||||
}
|
||||
|
||||
impl TransactionType {
|
||||
/// Returns true if this transaction type increases the balance
|
||||
/// (for standard asset accounts)
|
||||
pub fn is_balance_increasing(self) -> bool {
|
||||
matches!(self, TransactionType::Income | TransactionType::TransferIn)
|
||||
}
|
||||
|
||||
/// Returns true if this transaction type decreases the balance
|
||||
/// (for standard asset accounts)
|
||||
pub fn is_balance_decreasing(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TransactionType::Expense | TransactionType::TransferOut
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if this is a transfer type
|
||||
pub fn is_transfer(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TransactionType::TransferIn | TransactionType::TransferOut
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TransactionType {
|
||||
type Err = AppError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"income" => Ok(TransactionType::Income),
|
||||
"expense" => Ok(TransactionType::Expense),
|
||||
"transfer_in" => Ok(TransactionType::TransferIn),
|
||||
"transfer_out" => Ok(TransactionType::TransferOut),
|
||||
_ => Err(AppError::Validation(format!(
|
||||
"Invalid transaction type: {}. Expected one of: income, expense, transfer_in, transfer_out",
|
||||
s
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TransactionType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TransactionType::Income => write!(f, "income"),
|
||||
TransactionType::Expense => write!(f, "expense"),
|
||||
TransactionType::TransferIn => write!(f, "transfer_in"),
|
||||
TransactionType::TransferOut => write!(f, "transfer_out"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if account type is a liability (credit card, loan)
|
||||
pub fn is_liability_account(account_type: &str) -> bool {
|
||||
matches!(account_type.to_lowercase().as_str(), "credit_card" | "loan")
|
||||
}
|
||||
|
||||
/// Apply transaction amount to balance based on account type
|
||||
pub fn apply_transaction_to_balance(
|
||||
balance: &mut Decimal,
|
||||
amount: Decimal,
|
||||
txn_type: TransactionType,
|
||||
is_liability: bool,
|
||||
) {
|
||||
if is_liability {
|
||||
// For liability accounts: expenses increase debt, payments decrease debt
|
||||
match txn_type {
|
||||
TransactionType::Expense | TransactionType::TransferOut => *balance += amount,
|
||||
TransactionType::Income | TransactionType::TransferIn => *balance -= amount,
|
||||
}
|
||||
} else {
|
||||
// For asset accounts: normal balance calculation
|
||||
match txn_type {
|
||||
TransactionType::Expense | TransactionType::TransferOut => *balance -= amount,
|
||||
TransactionType::Income | TransactionType::TransferIn => *balance += amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a balance calculation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BalanceCalculationResult {
|
||||
pub balance: Decimal,
|
||||
pub transaction_count: usize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BalanceCalculator: Send + Sync {
|
||||
/// Calculate account balance by recalculating from all transactions
|
||||
/// This is the source of truth for balance calculations
|
||||
async fn calculate_balance(
|
||||
&self,
|
||||
account_id: &str,
|
||||
conn: &ConnectionSource<'_>,
|
||||
) -> CommandResult<BalanceCalculationResult>;
|
||||
|
||||
/// Calculate historical balance as of a specific date
|
||||
async fn calculate_historical_balance(
|
||||
&self,
|
||||
account_id: &str,
|
||||
as_of_date: &str,
|
||||
conn: &ConnectionSource<'_>,
|
||||
) -> CommandResult<BalanceCalculationResult>;
|
||||
}
|
||||
|
||||
pub struct BalanceCalculatorImpl;
|
||||
|
||||
impl BalanceCalculatorImpl {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BalanceCalculatorImpl {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BalanceCalculator for BalanceCalculatorImpl {
|
||||
async fn calculate_balance(
|
||||
&self,
|
||||
account_id: &str,
|
||||
conn: &ConnectionSource<'_>,
|
||||
) -> CommandResult<BalanceCalculationResult> {
|
||||
// Get account details
|
||||
let account = Accounts::find_by_id(account_id)
|
||||
.filter(accounts::Column::IsDeleted.eq(false))
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {}", account_id)))?;
|
||||
|
||||
let is_liability = is_liability_account(&account.account_type);
|
||||
let mut balance = Decimal::from_str(&account.initial_balance)?;
|
||||
let mut transaction_count = 0;
|
||||
|
||||
// Get all non-deleted transactions for this account
|
||||
let txns = Transactions::find()
|
||||
.filter(transactions::Column::AccountId.eq(account_id))
|
||||
.filter(transactions::Column::IsDeleted.eq(false))
|
||||
.order_by_asc(transactions::Column::TransactionDate)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
for txn in txns {
|
||||
let amount = Decimal::from_str(&txn.net_amount)?;
|
||||
let txn_type = TransactionType::from_str(&txn.transaction_type)?;
|
||||
apply_transaction_to_balance(&mut balance, amount, txn_type, is_liability);
|
||||
transaction_count += 1;
|
||||
}
|
||||
|
||||
Ok(BalanceCalculationResult {
|
||||
balance,
|
||||
transaction_count,
|
||||
})
|
||||
}
|
||||
|
||||
async fn calculate_historical_balance(
|
||||
&self,
|
||||
account_id: &str,
|
||||
as_of_date: &str,
|
||||
conn: &ConnectionSource<'_>,
|
||||
) -> CommandResult<BalanceCalculationResult> {
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if !regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$")
|
||||
.map_err(|_| AppError::Validation("Invalid regex pattern".to_string()))?
|
||||
.is_match(as_of_date)
|
||||
{
|
||||
return Err(AppError::Validation(
|
||||
"Invalid date format. Expected YYYY-MM-DD".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Get account details
|
||||
let account = Accounts::find_by_id(account_id)
|
||||
.filter(accounts::Column::IsDeleted.eq(false))
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {}", account_id)))?;
|
||||
|
||||
let is_liability = is_liability_account(&account.account_type);
|
||||
let mut balance = Decimal::from_str(&account.initial_balance)?;
|
||||
let mut transaction_count = 0;
|
||||
|
||||
// Get transactions up to and including the specified date
|
||||
let txns = Transactions::find()
|
||||
.filter(transactions::Column::AccountId.eq(account_id))
|
||||
.filter(transactions::Column::IsDeleted.eq(false))
|
||||
.filter(transactions::Column::TransactionDate.lte(as_of_date))
|
||||
.order_by_asc(transactions::Column::TransactionDate)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
for txn in txns {
|
||||
let amount = Decimal::from_str(&txn.net_amount)?;
|
||||
let txn_type = TransactionType::from_str(&txn.transaction_type)?;
|
||||
apply_transaction_to_balance(&mut balance, amount, txn_type, is_liability);
|
||||
transaction_count += 1;
|
||||
}
|
||||
|
||||
Ok(BalanceCalculationResult {
|
||||
balance,
|
||||
transaction_count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_transaction_type_from_str() {
|
||||
assert_eq!(
|
||||
TransactionType::from_str("income").expect("Valid type"),
|
||||
TransactionType::Income
|
||||
);
|
||||
assert_eq!(
|
||||
TransactionType::from_str("expense").expect("Valid type"),
|
||||
TransactionType::Expense
|
||||
);
|
||||
assert_eq!(
|
||||
TransactionType::from_str("transfer_in").expect("Valid type"),
|
||||
TransactionType::TransferIn
|
||||
);
|
||||
assert_eq!(
|
||||
TransactionType::from_str("transfer_out").expect("Valid type"),
|
||||
TransactionType::TransferOut
|
||||
);
|
||||
|
||||
// Case insensitive
|
||||
assert_eq!(
|
||||
TransactionType::from_str("INCOME").expect("Valid type"),
|
||||
TransactionType::Income
|
||||
);
|
||||
assert_eq!(
|
||||
TransactionType::from_str("Expense").expect("Valid type"),
|
||||
TransactionType::Expense
|
||||
);
|
||||
|
||||
// Invalid type
|
||||
assert!(TransactionType::from_str("invalid").is_err());
|
||||
assert!(TransactionType::from_str("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_type_display() {
|
||||
assert_eq!(TransactionType::Income.to_string(), "income");
|
||||
assert_eq!(TransactionType::Expense.to_string(), "expense");
|
||||
assert_eq!(TransactionType::TransferIn.to_string(), "transfer_in");
|
||||
assert_eq!(TransactionType::TransferOut.to_string(), "transfer_out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_type_helpers() {
|
||||
assert!(TransactionType::Income.is_balance_increasing());
|
||||
assert!(TransactionType::TransferIn.is_balance_increasing());
|
||||
assert!(!TransactionType::Expense.is_balance_increasing());
|
||||
assert!(!TransactionType::TransferOut.is_balance_increasing());
|
||||
|
||||
assert!(!TransactionType::Income.is_balance_decreasing());
|
||||
assert!(!TransactionType::TransferIn.is_balance_decreasing());
|
||||
assert!(TransactionType::Expense.is_balance_decreasing());
|
||||
assert!(TransactionType::TransferOut.is_balance_decreasing());
|
||||
|
||||
assert!(TransactionType::TransferIn.is_transfer());
|
||||
assert!(TransactionType::TransferOut.is_transfer());
|
||||
assert!(!TransactionType::Income.is_transfer());
|
||||
assert!(!TransactionType::Expense.is_transfer());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_liability_account() {
|
||||
assert!(is_liability_account("credit_card"));
|
||||
assert!(is_liability_account("CREDIT_CARD"));
|
||||
assert!(is_liability_account("Credit_Card"));
|
||||
assert!(is_liability_account("loan"));
|
||||
assert!(is_liability_account("LOAN"));
|
||||
assert!(!is_liability_account("checking"));
|
||||
assert!(!is_liability_account("savings"));
|
||||
assert!(!is_liability_account("cash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_transaction_to_balance_asset() {
|
||||
let mut balance = Decimal::from(1000);
|
||||
|
||||
// Expense decreases balance
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(100),
|
||||
TransactionType::Expense,
|
||||
false,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(900));
|
||||
|
||||
// Income increases balance
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(200),
|
||||
TransactionType::Income,
|
||||
false,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(1100));
|
||||
|
||||
// Transfer out decreases balance
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(50),
|
||||
TransactionType::TransferOut,
|
||||
false,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(1050));
|
||||
|
||||
// Transfer in increases balance
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(150),
|
||||
TransactionType::TransferIn,
|
||||
false,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(1200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_transaction_to_balance_liability() {
|
||||
let mut balance = Decimal::from(-1000); // Negative balance = debt
|
||||
|
||||
// Expense increases debt (balance becomes more negative)
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(100),
|
||||
TransactionType::Expense,
|
||||
true,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(-900));
|
||||
|
||||
// Income decreases debt (balance becomes less negative)
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(200),
|
||||
TransactionType::Income,
|
||||
true,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(-1100));
|
||||
|
||||
// Transfer out increases debt
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(50),
|
||||
TransactionType::TransferOut,
|
||||
true,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(-1050));
|
||||
|
||||
// Transfer in decreases debt
|
||||
apply_transaction_to_balance(
|
||||
&mut balance,
|
||||
Decimal::from(150),
|
||||
TransactionType::TransferIn,
|
||||
true,
|
||||
);
|
||||
assert_eq!(balance, Decimal::from(-1200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transaction_type_serde() {
|
||||
// Test serialization
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TransactionType::Income).expect("Serialize"),
|
||||
"\"income\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TransactionType::Expense).expect("Serialize"),
|
||||
"\"expense\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TransactionType::TransferIn).expect("Serialize"),
|
||||
"\"transfer_in\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TransactionType::TransferOut).expect("Serialize"),
|
||||
"\"transfer_out\""
|
||||
);
|
||||
|
||||
// Test deserialization
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TransactionType>("\"income\"").expect("Deserialize"),
|
||||
TransactionType::Income
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TransactionType>("\"expense\"").expect("Deserialize"),
|
||||
TransactionType::Expense
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TransactionType>("\"transfer_in\"").expect("Deserialize"),
|
||||
TransactionType::TransferIn
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TransactionType>("\"transfer_out\"").expect("Deserialize"),
|
||||
TransactionType::TransferOut
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ use sea_orm::DatabaseConnection;
|
||||
use crate::errors::CommandResult;
|
||||
|
||||
pub mod accounts;
|
||||
pub mod balance_calculator;
|
||||
pub mod exchange_rate;
|
||||
pub mod settings;
|
||||
pub mod transactions;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ServiceTrait: Send + Sync {
|
||||
@@ -20,6 +22,7 @@ pub struct ServiceFactory;
|
||||
|
||||
pub struct ServiceFactoryResult {
|
||||
pub account_service: Arc<dyn accounts::service::AccountService>,
|
||||
pub transaction_service: Arc<dyn transactions::service::TransactionService>,
|
||||
pub settings_service: Arc<dyn settings::service::SettingsService>,
|
||||
pub exchange_rate_service: Arc<dyn exchange_rate::service::ExchangeRateService>,
|
||||
}
|
||||
@@ -27,6 +30,7 @@ pub struct ServiceFactoryResult {
|
||||
impl ServiceFactory {
|
||||
pub async fn create_services(db: DatabaseConnection) -> ServiceFactoryResult {
|
||||
let account_service = Arc::new(accounts::service::AccountServiceImpl::new(db.clone()));
|
||||
let transaction_service = Arc::new(transactions::service::TransactionServiceImpl::new(db.clone()));
|
||||
let settings_service = Arc::new(settings::service::SettingsServiceImpl::new(db.clone()));
|
||||
let exchange_rate_service = Arc::new(
|
||||
exchange_rate::service::ExchangeRateServiceImpl::new(
|
||||
@@ -37,6 +41,7 @@ impl ServiceFactory {
|
||||
);
|
||||
ServiceFactoryResult {
|
||||
account_service,
|
||||
transaction_service,
|
||||
exchange_rate_service,
|
||||
settings_service,
|
||||
}
|
||||
|
||||
2
src-tauri/src/services/transactions/mod.rs
Normal file
2
src-tauri/src/services/transactions/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
1532
src-tauri/src/services/transactions/service.rs
Normal file
1532
src-tauri/src/services/transactions/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
80
src-tauri/src/services/transactions/types/inputs.rs
Normal file
80
src-tauri/src/services/transactions/types/inputs.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::services::balance_calculator::service::TransactionType;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTransactionInput {
|
||||
pub account_id: String,
|
||||
pub transaction_type: TransactionType,
|
||||
pub gross_amount: String,
|
||||
pub tax_amount: Option<String>,
|
||||
pub net_amount: String,
|
||||
pub currency: String,
|
||||
pub description: String,
|
||||
pub merchant: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub receipt_paths: Option<Vec<String>>,
|
||||
pub transaction_date: String,
|
||||
pub tag_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BulkCreateTransactionInput {
|
||||
pub transactions: Vec<CreateTransactionInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct UpdateTransactionInput {
|
||||
pub gross_amount: Option<String>,
|
||||
pub tax_amount: Option<String>,
|
||||
pub net_amount: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub merchant: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub transaction_date: Option<String>,
|
||||
pub receipt_paths: Option<Vec<String>>,
|
||||
pub tag_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct TransactionFilter {
|
||||
pub account_id: Option<String>,
|
||||
pub transaction_type: Option<TransactionType>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub needs_review: Option<bool>,
|
||||
pub is_auto_inserted: Option<bool>,
|
||||
pub search_query: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
pub limit: Option<u64>,
|
||||
pub offset: Option<u64>,
|
||||
/// Filter by tag IDs (optional)
|
||||
pub tag_ids: Option<Vec<String>>,
|
||||
/// If true, transaction must have ALL specified tags. If false, ANY tag matches.
|
||||
pub match_all_tags: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BulkDeleteInput {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BulkDeleteResult {
|
||||
pub deleted_count: usize,
|
||||
pub failed_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct TransactionStatistics {
|
||||
pub total_income: String,
|
||||
pub total_expense: String,
|
||||
pub total_transfer_in: String,
|
||||
pub total_transfer_out: String,
|
||||
pub net_flow: String,
|
||||
pub count_income: i64,
|
||||
pub count_expense: i64,
|
||||
pub count_transfer: i64,
|
||||
pub total_count: i64,
|
||||
}
|
||||
2
src-tauri/src/services/transactions/types/mod.rs
Normal file
2
src-tauri/src/services/transactions/types/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod inputs;
|
||||
pub mod outputs;
|
||||
15
src-tauri/src/services/transactions/types/outputs.rs
Normal file
15
src-tauri/src/services/transactions/types/outputs.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::entities::transactions;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TransactionWithTags {
|
||||
pub transaction: transactions::Model,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BulkDeleteResult {
|
||||
pub deleted_count: usize,
|
||||
pub failed_ids: Vec<String>,
|
||||
}
|
||||
@@ -5,11 +5,13 @@ use crate::{
|
||||
services::{
|
||||
ServiceFactoryResult, ServiceTrait, accounts::service::AccountService,
|
||||
exchange_rate::service::ExchangeRateService, settings::service::SettingsService,
|
||||
transactions::service::TransactionService,
|
||||
},
|
||||
};
|
||||
pub struct AppState {
|
||||
db: DbService,
|
||||
account_service: Arc<dyn AccountService>,
|
||||
transaction_service: Arc<dyn TransactionService>,
|
||||
settings_service: Arc<dyn SettingsService>,
|
||||
exchange_rate_service: Arc<dyn ExchangeRateService>,
|
||||
}
|
||||
@@ -20,6 +22,7 @@ impl AppState {
|
||||
Self {
|
||||
db,
|
||||
account_service: services.account_service,
|
||||
transaction_service: services.transaction_service,
|
||||
settings_service: services.settings_service,
|
||||
exchange_rate_service: services.exchange_rate_service,
|
||||
}
|
||||
@@ -34,6 +37,11 @@ impl AppState {
|
||||
&self.account_service
|
||||
}
|
||||
|
||||
/// Get the transaction service
|
||||
pub fn transaction_service(&self) -> &Arc<dyn TransactionService> {
|
||||
&self.transaction_service
|
||||
}
|
||||
|
||||
/// Get the settings service
|
||||
pub fn settings_service(&self) -> &Arc<dyn SettingsService> {
|
||||
&self.settings_service
|
||||
@@ -49,6 +57,7 @@ impl AppState {
|
||||
self.settings_service.on_app_start().await?;
|
||||
self.exchange_rate_service.on_app_start().await?;
|
||||
self.account_service.on_app_start().await?;
|
||||
self.transaction_service.on_app_start().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user