diff --git a/outlay-core/src/db.rs b/outlay-core/src/db.rs index 10e44a5..8e88dc2 100644 --- a/outlay-core/src/db.rs +++ b/outlay-core/src/db.rs @@ -231,6 +231,44 @@ impl Database { rows.collect() } + pub fn list_all_transactions( + &self, + from: Option, + to: Option, + ) -> SqlResult> { + let (sql, params_vec): (&str, Vec) = match (from, to) { + (Some(f), Some(t)) => ( + "SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id + FROM transactions WHERE date >= ?1 AND date <= ?2 ORDER BY date ASC, id ASC", + vec![f.format("%Y-%m-%d").to_string(), t.format("%Y-%m-%d").to_string()], + ), + (Some(f), None) => ( + "SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id + FROM transactions WHERE date >= ?1 ORDER BY date ASC, id ASC", + vec![f.format("%Y-%m-%d").to_string()], + ), + (None, Some(t)) => ( + "SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id + FROM transactions WHERE date <= ?1 ORDER BY date ASC, id ASC", + vec![t.format("%Y-%m-%d").to_string()], + ), + (None, None) => ( + "SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id + FROM transactions ORDER BY date ASC, id ASC", + vec![], + ), + }; + let mut stmt = self.conn.prepare(sql)?; + let param_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec + .iter() + .map(|s| s as &dyn rusqlite::types::ToSql) + .collect(); + let rows = stmt.query_map(param_refs.as_slice(), |row| { + Self::row_to_transaction(row) + })?; + rows.collect() + } + fn row_to_transaction(row: &rusqlite::Row) -> SqlResult { let type_str: String = row.get(2)?; let date_str: String = row.get(7)?; diff --git a/outlay-core/src/export_csv.rs b/outlay-core/src/export_csv.rs index e69de29..cbfaea7 100644 --- a/outlay-core/src/export_csv.rs +++ b/outlay-core/src/export_csv.rs @@ -0,0 +1,189 @@ +use crate::db::Database; +use chrono::NaiveDate; +use csv::Writer; +use std::io::Write; + +#[derive(Debug)] +pub enum ExportError { + Db(rusqlite::Error), + Csv(csv::Error), +} + +impl From for ExportError { + fn from(e: rusqlite::Error) -> Self { + ExportError::Db(e) + } +} + +impl From for ExportError { + fn from(e: csv::Error) -> Self { + ExportError::Csv(e) + } +} + +impl std::fmt::Display for ExportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExportError::Db(e) => write!(f, "Database error: {}", e), + ExportError::Csv(e) => write!(f, "CSV error: {}", e), + } + } +} + +pub fn export_transactions_csv( + db: &Database, + writer: W, + from: Option, + to: Option, +) -> Result { + let transactions = db.list_all_transactions(from, to)?; + let mut wtr = Writer::from_writer(writer); + + wtr.write_record(["Date", "Type", "Category", "Amount", "Currency", "Exchange Rate", "Note"])?; + + for txn in &transactions { + let cat_name = db + .get_category(txn.category_id) + .map(|c| c.name) + .unwrap_or_else(|_| "Unknown".to_string()); + + wtr.write_record(&[ + txn.date.format("%Y-%m-%d").to_string(), + txn.transaction_type.as_str().to_string(), + cat_name, + format!("{:.2}", txn.amount), + txn.currency.clone(), + format!("{:.4}", txn.exchange_rate), + txn.note.clone().unwrap_or_default(), + ])?; + } + + wtr.flush().map_err(|e| ExportError::Csv(e.into()))?; + Ok(transactions.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{NewTransaction, TransactionType}; + + fn setup_db() -> Database { + Database::open_in_memory().unwrap() + } + + #[test] + fn test_csv_export_format() { + let db = setup_db(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + let cat = &cats[0]; + + let txn = NewTransaction { + amount: 42.50, + transaction_type: TransactionType::Expense, + category_id: cat.id, + currency: "USD".to_string(), + exchange_rate: 1.0, + note: Some("Lunch".to_string()), + date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + recurring_id: None, + }; + db.insert_transaction(&txn).unwrap(); + + let mut buf = Vec::new(); + let count = export_transactions_csv(&db, &mut buf, None, None).unwrap(); + assert_eq!(count, 1); + + let output = String::from_utf8(buf).unwrap(); + let lines: Vec<&str> = output.trim().lines().collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], "Date,Type,Category,Amount,Currency,Exchange Rate,Note"); + assert!(lines[1].contains("2026-03-01")); + assert!(lines[1].contains("expense")); + assert!(lines[1].contains("42.50")); + assert!(lines[1].contains("Lunch")); + } + + #[test] + fn test_csv_export_empty() { + let db = setup_db(); + let mut buf = Vec::new(); + let count = export_transactions_csv(&db, &mut buf, None, None).unwrap(); + assert_eq!(count, 0); + + let output = String::from_utf8(buf).unwrap(); + let lines: Vec<&str> = output.trim().lines().collect(); + assert_eq!(lines.len(), 1); // header only + } + + #[test] + fn test_csv_export_filtered_by_date() { + let db = setup_db(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + let cat_id = cats[0].id; + + for day in 1..=5 { + let txn = NewTransaction { + amount: 10.0 * day as f64, + transaction_type: TransactionType::Expense, + category_id: cat_id, + currency: "USD".to_string(), + exchange_rate: 1.0, + note: None, + date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(), + recurring_id: None, + }; + db.insert_transaction(&txn).unwrap(); + } + + // Filter from Jan 2 to Jan 4 + let mut buf = Vec::new(); + let count = export_transactions_csv( + &db, + &mut buf, + Some(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()), + Some(NaiveDate::from_ymd_opt(2026, 1, 4).unwrap()), + ) + .unwrap(); + assert_eq!(count, 3); + } + + #[test] + fn test_csv_export_multiple_types() { + let db = setup_db(); + let expense_cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + let income_cats = db.list_categories(Some(TransactionType::Income)).unwrap(); + + let txn1 = NewTransaction { + amount: 50.0, + transaction_type: TransactionType::Expense, + category_id: expense_cats[0].id, + currency: "USD".to_string(), + exchange_rate: 1.0, + note: None, + date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), + recurring_id: None, + }; + let txn2 = NewTransaction { + amount: 1000.0, + transaction_type: TransactionType::Income, + category_id: income_cats[0].id, + currency: "EUR".to_string(), + exchange_rate: 0.92, + note: Some("Salary".to_string()), + date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), + recurring_id: None, + }; + db.insert_transaction(&txn1).unwrap(); + db.insert_transaction(&txn2).unwrap(); + + let mut buf = Vec::new(); + let count = export_transactions_csv(&db, &mut buf, None, None).unwrap(); + assert_eq!(count, 2); + + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("expense")); + assert!(output.contains("income")); + assert!(output.contains("EUR")); + assert!(output.contains("0.9200")); + } +}