4 Commits

Author SHA1 Message Date
d60a573c64 feat(transactions): refactor balance calculation using BalanceCalculator service 2026-02-25 10:58:23 +00:00
e731c45a71 feat(transactions): implement transaction management commands 2026-02-25 10:58:03 +00:00
bfbb771cbf feat(transactions): add input and output types for transaction management
- Introduced `CreateTransactionInput`, `BulkCreateTransactionInput`, `UpdateTransactionInput`, and `TransactionFilter` structs for handling transaction creation and updates.
- Added `BulkDeleteInput` and `BulkDeleteResult` structs for bulk deletion of transactions.
- Created `TransactionStatistics` struct to encapsulate transaction statistics data.
- Established a new module for transaction types in `mod.rs`.
- Implemented `TransactionWithTags` struct in outputs for returning transactions with associated tags.
- Updated `AppState` to include a transaction service, ensuring it is initialized and accessible.
2026-02-25 10:57:48 +00:00
30eb0b71cc feat: add balance calculator service module and transaction type handling 2026-02-25 10:57:24 +00:00
13 changed files with 2343 additions and 81 deletions

View File

@@ -1,3 +1,4 @@
pub mod accounts;
pub mod exchange_rate;
pub mod settings;
pub mod transactions;

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
pub mod service;

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

View File

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

View File

@@ -0,0 +1,2 @@
pub mod service;
pub mod types;

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,2 @@
pub mod inputs;
pub mod outputs;

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

View File

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