envelope_cli/export/
csv.rs

1//! CSV Export functionality
2//!
3//! Exports transactions, budget allocations, and account data to CSV format.
4
5use crate::error::EnvelopeResult;
6use crate::models::{BudgetPeriod, TransactionStatus};
7use crate::services::{AccountService, BudgetService, CategoryService};
8use crate::storage::Storage;
9use std::io::Write;
10
11/// Export all transactions to CSV
12pub fn export_transactions_csv<W: Write>(storage: &Storage, writer: &mut W) -> EnvelopeResult<()> {
13    let category_service = CategoryService::new(storage);
14    let account_service = AccountService::new(storage);
15
16    // Build lookups
17    let categories = category_service.list_categories()?;
18    let category_names: std::collections::HashMap<_, _> =
19        categories.iter().map(|c| (c.id, c.name.clone())).collect();
20
21    let accounts = account_service.list(true)?;
22    let account_names: std::collections::HashMap<_, _> =
23        accounts.iter().map(|a| (a.id, a.name.clone())).collect();
24
25    // Write header
26    writeln!(
27        writer,
28        "ID,Date,Account,Payee,Category,Memo,Amount,Status,Is Split,Is Transfer"
29    )
30    .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
31
32    // Get all transactions
33    let transactions = storage.transactions.get_all()?;
34
35    for txn in transactions {
36        let account_name = account_names
37            .get(&txn.account_id)
38            .cloned()
39            .unwrap_or_else(|| "Unknown".to_string());
40
41        let category_name = if txn.is_transfer() {
42            "Transfer".to_string()
43        } else if txn.is_split() {
44            "Split".to_string()
45        } else if let Some(cat_id) = txn.category_id {
46            category_names
47                .get(&cat_id)
48                .cloned()
49                .unwrap_or_else(|| "Unknown".to_string())
50        } else {
51            "".to_string()
52        };
53
54        let status = match txn.status {
55            TransactionStatus::Pending => "Pending",
56            TransactionStatus::Cleared => "Cleared",
57            TransactionStatus::Reconciled => "Reconciled",
58        };
59
60        writeln!(
61            writer,
62            "{},{},{},{},{},{},{:.2},{},{},{}",
63            txn.id,
64            txn.date,
65            escape_csv(&account_name),
66            escape_csv(&txn.payee_name),
67            escape_csv(&category_name),
68            escape_csv(&txn.memo),
69            txn.amount.cents() as f64 / 100.0,
70            status,
71            txn.is_split(),
72            txn.is_transfer()
73        )
74        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
75
76        // If split transaction, also export split details
77        if txn.is_split() {
78            for split in &txn.splits {
79                let split_cat_name = category_names
80                    .get(&split.category_id)
81                    .cloned()
82                    .unwrap_or_else(|| "Unknown".to_string());
83
84                writeln!(
85                    writer,
86                    "{}-split,{},{},{},{},{},{:.2},{},true,false",
87                    txn.id,
88                    txn.date,
89                    escape_csv(&account_name),
90                    escape_csv(&txn.payee_name),
91                    escape_csv(&split_cat_name),
92                    escape_csv(&split.memo),
93                    split.amount.cents() as f64 / 100.0,
94                    status
95                )
96                .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
97            }
98        }
99    }
100
101    Ok(())
102}
103
104/// Export budget allocations to CSV
105pub fn export_allocations_csv<W: Write>(
106    storage: &Storage,
107    writer: &mut W,
108    periods: Option<Vec<BudgetPeriod>>,
109) -> EnvelopeResult<()> {
110    let category_service = CategoryService::new(storage);
111    let budget_service = BudgetService::new(storage);
112
113    // Build category lookup
114    let categories = category_service.list_categories()?;
115    let groups = category_service.list_groups()?;
116
117    let group_names: std::collections::HashMap<_, _> =
118        groups.iter().map(|g| (g.id, g.name.clone())).collect();
119
120    // Write header
121    writeln!(
122        writer,
123        "Period,Group,Category,Budgeted,Carryover,Activity,Available"
124    )
125    .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
126
127    // Determine which periods to export
128    let periods_to_export = if let Some(p) = periods {
129        p
130    } else {
131        // Export last 12 months by default
132        let current = BudgetPeriod::current_month();
133        (0..12)
134            .map(|i| {
135                let mut p = current.clone();
136                for _ in 0..i {
137                    p = p.prev();
138                }
139                p
140            })
141            .collect()
142    };
143
144    for period in periods_to_export {
145        for category in &categories {
146            let summary = budget_service.get_category_summary(category.id, &period)?;
147            let group_name = group_names
148                .get(&category.group_id)
149                .cloned()
150                .unwrap_or_else(|| "Unknown".to_string());
151
152            writeln!(
153                writer,
154                "{},{},{},{:.2},{:.2},{:.2},{:.2}",
155                period,
156                escape_csv(&group_name),
157                escape_csv(&category.name),
158                summary.budgeted.cents() as f64 / 100.0,
159                summary.carryover.cents() as f64 / 100.0,
160                summary.activity.cents() as f64 / 100.0,
161                summary.available.cents() as f64 / 100.0
162            )
163            .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
164        }
165    }
166
167    Ok(())
168}
169
170/// Export accounts to CSV
171pub fn export_accounts_csv<W: Write>(storage: &Storage, writer: &mut W) -> EnvelopeResult<()> {
172    let account_service = AccountService::new(storage);
173    let summaries = account_service.list_with_balances(true)?;
174
175    // Write header
176    writeln!(
177        writer,
178        "ID,Name,Type,On Budget,Archived,Starting Balance,Current Balance,Cleared Balance,Uncleared Count"
179    )
180    .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
181
182    for summary in summaries {
183        writeln!(
184            writer,
185            "{},{},{:?},{},{},{:.2},{:.2},{:.2},{}",
186            summary.account.id,
187            escape_csv(&summary.account.name),
188            summary.account.account_type,
189            summary.account.on_budget,
190            summary.account.archived,
191            summary.account.starting_balance.cents() as f64 / 100.0,
192            summary.balance.cents() as f64 / 100.0,
193            summary.cleared_balance.cents() as f64 / 100.0,
194            summary.uncleared_count
195        )
196        .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
197    }
198
199    Ok(())
200}
201
202/// Escape a string for CSV format
203fn escape_csv(s: &str) -> String {
204    if s.contains(',') || s.contains('"') || s.contains('\n') {
205        format!("\"{}\"", s.replace('"', "\"\""))
206    } else {
207        s.to_string()
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::config::paths::EnvelopePaths;
215    use crate::models::{Account, AccountType, Category, CategoryGroup, Money, Transaction};
216    use chrono::NaiveDate;
217    use tempfile::TempDir;
218
219    fn create_test_storage() -> (TempDir, Storage) {
220        let temp_dir = TempDir::new().unwrap();
221        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
222        let mut storage = Storage::new(paths).unwrap();
223        storage.load_all().unwrap();
224        (temp_dir, storage)
225    }
226
227    #[test]
228    fn test_export_transactions_csv() {
229        let (_temp_dir, storage) = create_test_storage();
230
231        // Create test data
232        let account = Account::new("Checking", AccountType::Checking);
233        storage.accounts.upsert(account.clone()).unwrap();
234        storage.accounts.save().unwrap();
235
236        let group = CategoryGroup::new("Test");
237        storage.categories.upsert_group(group.clone()).unwrap();
238        let cat = Category::new("Groceries", group.id);
239        storage.categories.upsert_category(cat.clone()).unwrap();
240        storage.categories.save().unwrap();
241
242        let mut txn = Transaction::new(
243            account.id,
244            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
245            Money::from_cents(-5000),
246        );
247        txn.payee_name = "Test Store".to_string();
248        txn.category_id = Some(cat.id);
249        storage.transactions.upsert(txn).unwrap();
250
251        let mut csv_output = Vec::new();
252        export_transactions_csv(&storage, &mut csv_output).unwrap();
253
254        let csv_string = String::from_utf8(csv_output).unwrap();
255        assert!(csv_string.contains("ID,Date,Account,Payee"));
256        assert!(csv_string.contains("Test Store"));
257        assert!(csv_string.contains("Groceries"));
258    }
259
260    #[test]
261    fn test_export_accounts_csv() {
262        let (_temp_dir, storage) = create_test_storage();
263
264        let account = Account::with_starting_balance(
265            "Checking",
266            AccountType::Checking,
267            Money::from_cents(100000),
268        );
269        storage.accounts.upsert(account).unwrap();
270        storage.accounts.save().unwrap();
271
272        let mut csv_output = Vec::new();
273        export_accounts_csv(&storage, &mut csv_output).unwrap();
274
275        let csv_string = String::from_utf8(csv_output).unwrap();
276        assert!(csv_string.contains("ID,Name,Type"));
277        assert!(csv_string.contains("Checking"));
278        assert!(csv_string.contains("1000.00"));
279    }
280}