1use crate::error::EnvelopeResult;
6use crate::models::{BudgetPeriod, TransactionStatus};
7use crate::services::{AccountService, BudgetService, CategoryService};
8use crate::storage::Storage;
9use std::io::Write;
10
11pub 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 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 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 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 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
104pub 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 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 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 let periods_to_export = if let Some(p) = periods {
129 p
130 } else {
131 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
170pub 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 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
202fn 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 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}