Add CSV export for transactions with date filtering
This commit is contained in:
@@ -231,6 +231,44 @@ impl Database {
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn list_all_transactions(
|
||||
&self,
|
||||
from: Option<NaiveDate>,
|
||||
to: Option<NaiveDate>,
|
||||
) -> SqlResult<Vec<Transaction>> {
|
||||
let (sql, params_vec): (&str, Vec<String>) = 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<Transaction> {
|
||||
let type_str: String = row.get(2)?;
|
||||
let date_str: String = row.get(7)?;
|
||||
|
||||
@@ -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<rusqlite::Error> for ExportError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
ExportError::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<csv::Error> 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<W: Write>(
|
||||
db: &Database,
|
||||
writer: W,
|
||||
from: Option<NaiveDate>,
|
||||
to: Option<NaiveDate>,
|
||||
) -> Result<usize, ExportError> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user