Files
outlay/outlay-core/src/export_csv.rs
lashman 10a76e3003 Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
- Implement subscriptions view with bidirectional recurring transaction sync
- Add cascade delete/pause/resume between subscriptions and recurring
- Fix foreign key constraints when deleting recurring transactions
- Add cross-view instant refresh via callback pattern
- Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation
- Smooth budget sparklines using shared monotone_subdivide function
- Add vertical spacing to budget rows
- Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage
- Add calendar, credit cards, forecast, goals, insights, and wishlist views
- Add date picker, numpad, quick-add, category combo, and edit dialog components
- Add import/export for CSV, JSON, OFX, QIF formats
- Add NLP transaction parsing, OCR receipt scanning, expression evaluator
- Add notification support, Sankey chart, tray icon
- Add demo data seeder with full DB wipe
- Expand database schema with subscriptions, goals, credit cards, and more
2026-03-03 21:18:37 +02:00

195 lines
6.0 KiB
Rust

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", "Payee"])?;
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(),
txn.payee.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,
payee: 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,Payee");
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,
payee: 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,
payee: 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,
payee: 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"));
}
}