use crate::error::EnvelopeResult;
use crate::models::{AccountId, CategoryId, Money, Transaction, TransactionStatus};
use crate::services::{AccountService, CategoryService};
use crate::storage::Storage;
use chrono::NaiveDate;
use std::io::Write;
#[derive(Debug, Clone)]
pub struct RegisterEntry {
pub date: NaiveDate,
pub payee: String,
pub category: String,
pub memo: String,
pub amount: Money,
pub running_balance: Money,
pub status: TransactionStatus,
pub is_split: bool,
pub is_transfer: bool,
}
#[derive(Debug, Clone, Default)]
pub struct RegisterFilter {
pub start_date: Option<NaiveDate>,
pub end_date: Option<NaiveDate>,
pub category_id: Option<CategoryId>,
pub status: Option<TransactionStatus>,
pub payee_contains: Option<String>,
pub min_amount: Option<Money>,
pub max_amount: Option<Money>,
pub uncategorized_only: bool,
}
impl RegisterFilter {
pub fn matches(&self, txn: &Transaction) -> bool {
if let Some(start) = self.start_date {
if txn.date < start {
return false;
}
}
if let Some(end) = self.end_date {
if txn.date > end {
return false;
}
}
if let Some(cat_id) = self.category_id {
let matches_category = txn.category_id == Some(cat_id)
|| txn.splits.iter().any(|s| s.category_id == cat_id);
if !matches_category {
return false;
}
}
if let Some(status) = self.status {
if txn.status != status {
return false;
}
}
if let Some(ref payee) = self.payee_contains {
if !txn
.payee_name
.to_lowercase()
.contains(&payee.to_lowercase())
{
return false;
}
}
let abs_amount = txn.amount.abs();
if let Some(min) = self.min_amount {
if abs_amount < min {
return false;
}
}
if let Some(max) = self.max_amount {
if abs_amount > max {
return false;
}
}
if self.uncategorized_only
&& (txn.category_id.is_some() || !txn.splits.is_empty() || txn.is_transfer())
{
return false;
}
true
}
}
#[derive(Debug, Clone)]
pub struct AccountRegisterReport {
pub account_id: AccountId,
pub account_name: String,
pub starting_balance: Money,
pub ending_balance: Money,
pub entries: Vec<RegisterEntry>,
pub total_inflows: Money,
pub total_outflows: Money,
pub filter: RegisterFilter,
}
impl AccountRegisterReport {
pub fn generate(
storage: &Storage,
account_id: AccountId,
filter: RegisterFilter,
) -> EnvelopeResult<Self> {
let account_service = AccountService::new(storage);
let category_service = CategoryService::new(storage);
let account = account_service.get(account_id)?.ok_or_else(|| {
crate::error::EnvelopeError::account_not_found(account_id.to_string())
})?;
let categories = category_service.list_categories()?;
let category_names: std::collections::HashMap<CategoryId, String> =
categories.iter().map(|c| (c.id, c.name.clone())).collect();
let mut transactions = storage.transactions.get_by_account(account_id)?;
transactions.sort_by(|a, b| {
a.date
.cmp(&b.date)
.then_with(|| a.created_at.cmp(&b.created_at))
});
let mut starting_balance = account.starting_balance;
if let Some(start_date) = filter.start_date {
for txn in &transactions {
if txn.date < start_date {
starting_balance += txn.amount;
}
}
}
let mut entries = Vec::new();
let mut running_balance = starting_balance;
let mut total_inflows = Money::zero();
let mut total_outflows = Money::zero();
for txn in &transactions {
if !filter.matches(txn) {
continue;
}
running_balance += txn.amount;
if txn.amount.is_positive() {
total_inflows += txn.amount;
} else {
total_outflows += txn.amount;
}
let category = if txn.is_transfer() {
"Transfer".to_string()
} else if txn.is_split() {
"Split".to_string()
} else if let Some(cat_id) = txn.category_id {
category_names
.get(&cat_id)
.cloned()
.unwrap_or_else(|| "Unknown".to_string())
} else {
"Uncategorized".to_string()
};
entries.push(RegisterEntry {
date: txn.date,
payee: txn.payee_name.clone(),
category,
memo: txn.memo.clone(),
amount: txn.amount,
running_balance,
status: txn.status,
is_split: txn.is_split(),
is_transfer: txn.is_transfer(),
});
}
Ok(Self {
account_id,
account_name: account.name.clone(),
starting_balance,
ending_balance: running_balance,
entries,
total_inflows,
total_outflows,
filter,
})
}
pub fn format_terminal(&self) -> String {
let mut output = String::new();
output.push_str(&format!("Account Register: {}\n", self.account_name));
output.push_str(&"=".repeat(100));
output.push('\n');
if let Some(start) = self.filter.start_date {
output.push_str(&format!("From: {} ", start));
}
if let Some(end) = self.filter.end_date {
output.push_str(&format!("To: {} ", end));
}
output.push('\n');
output.push_str(&format!("Starting Balance: {}\n", self.starting_balance));
output.push_str(&format!("Ending Balance: {}\n\n", self.ending_balance));
output.push_str(&format!(
"{:<12} {:<20} {:<20} {:>12} {:>12} {:>4}\n",
"Date", "Payee", "Category", "Amount", "Balance", "Clr"
));
output.push_str(&"-".repeat(100));
output.push('\n');
for entry in &self.entries {
let status_char = match entry.status {
TransactionStatus::Pending => " ",
TransactionStatus::Cleared => "C",
TransactionStatus::Reconciled => "R",
};
let payee_display = if entry.payee.len() > 18 {
format!("{}...", &entry.payee[..15])
} else {
entry.payee.clone()
};
let category_display = if entry.category.len() > 18 {
format!("{}...", &entry.category[..15])
} else {
entry.category.clone()
};
output.push_str(&format!(
"{:<12} {:<20} {:<20} {:>12} {:>12} {:>4}\n",
entry.date,
payee_display,
category_display,
entry.amount,
entry.running_balance,
status_char
));
}
output.push_str(&"-".repeat(100));
output.push('\n');
output.push_str(&format!(
"Total Inflows: {} | Total Outflows: {} | Transactions: {}\n",
self.total_inflows,
self.total_outflows.abs(),
self.entries.len()
));
output
}
pub fn export_csv<W: Write>(&self, writer: &mut W) -> EnvelopeResult<()> {
writeln!(
writer,
"Account,Date,Payee,Category,Memo,Amount,Running Balance,Status"
)
.map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
for entry in &self.entries {
let status = match entry.status {
TransactionStatus::Pending => "Pending",
TransactionStatus::Cleared => "Cleared",
TransactionStatus::Reconciled => "Reconciled",
};
let payee = escape_csv_field(&entry.payee);
let category = escape_csv_field(&entry.category);
let memo = escape_csv_field(&entry.memo);
writeln!(
writer,
"{},{},{},{},{},{:.2},{:.2},{}",
self.account_name,
entry.date,
payee,
category,
memo,
entry.amount.cents() as f64 / 100.0,
entry.running_balance.cents() as f64 / 100.0,
status
)
.map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
}
Ok(())
}
pub fn summary(&self) -> RegisterSummary {
let cleared_count = self
.entries
.iter()
.filter(|e| {
matches!(
e.status,
TransactionStatus::Cleared | TransactionStatus::Reconciled
)
})
.count();
let pending_count = self
.entries
.iter()
.filter(|e| e.status == TransactionStatus::Pending)
.count();
RegisterSummary {
total_entries: self.entries.len(),
cleared_count,
pending_count,
total_inflows: self.total_inflows,
total_outflows: self.total_outflows,
net_change: self.total_inflows + self.total_outflows,
}
}
}
#[derive(Debug, Clone)]
pub struct RegisterSummary {
pub total_entries: usize,
pub cleared_count: usize,
pub pending_count: usize,
pub total_inflows: Money,
pub total_outflows: Money,
pub net_change: Money,
}
fn escape_csv_field(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::paths::EnvelopePaths;
use crate::models::{Account, AccountType, Category, CategoryGroup};
use tempfile::TempDir;
fn create_test_storage() -> (TempDir, Storage) {
let temp_dir = TempDir::new().unwrap();
let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
let mut storage = Storage::new(paths).unwrap();
storage.load_all().unwrap();
(temp_dir, storage)
}
#[test]
fn test_generate_register_report() {
let (_temp_dir, storage) = create_test_storage();
let account = Account::with_starting_balance(
"Checking",
AccountType::Checking,
Money::from_cents(100000),
);
storage.accounts.upsert(account.clone()).unwrap();
storage.accounts.save().unwrap();
let group = CategoryGroup::new("Test");
storage.categories.upsert_group(group.clone()).unwrap();
let cat = Category::new("Groceries", group.id);
storage.categories.upsert_category(cat.clone()).unwrap();
storage.categories.save().unwrap();
let mut txn1 = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
Money::from_cents(-5000),
);
txn1.payee_name = "Grocery Store".to_string();
txn1.category_id = Some(cat.id);
storage.transactions.upsert(txn1).unwrap();
let txn2 = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
Money::from_cents(200000),
);
storage.transactions.upsert(txn2).unwrap();
let report =
AccountRegisterReport::generate(&storage, account.id, RegisterFilter::default())
.unwrap();
assert_eq!(report.entries.len(), 2);
assert_eq!(report.starting_balance.cents(), 100000);
assert_eq!(report.ending_balance.cents(), 295000);
}
#[test]
fn test_register_filter() {
let (_temp_dir, storage) = create_test_storage();
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
for day in 1..10 {
let txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 1, day).unwrap(),
Money::from_cents(-1000),
);
storage.transactions.upsert(txn).unwrap();
}
let filter = RegisterFilter {
start_date: Some(NaiveDate::from_ymd_opt(2025, 1, 3).unwrap()),
end_date: Some(NaiveDate::from_ymd_opt(2025, 1, 7).unwrap()),
..Default::default()
};
let report = AccountRegisterReport::generate(&storage, account.id, filter).unwrap();
assert_eq!(report.entries.len(), 5); }
#[test]
fn test_csv_export() {
let (_temp_dir, storage) = create_test_storage();
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
let mut txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
Money::from_cents(-5000),
);
txn.payee_name = "Test Payee".to_string();
storage.transactions.upsert(txn).unwrap();
let report =
AccountRegisterReport::generate(&storage, account.id, RegisterFilter::default())
.unwrap();
let mut csv_output = Vec::new();
report.export_csv(&mut csv_output).unwrap();
let csv_string = String::from_utf8(csv_output).unwrap();
assert!(csv_string.contains("Account,Date,Payee,Category,Memo,Amount"));
assert!(csv_string.contains("Test Payee"));
}
}