envelope_cli/cli/
transaction.rs

1//! Transaction CLI commands
2//!
3//! Implements CLI commands for transaction management.
4
5use chrono::NaiveDate;
6use clap::Subcommand;
7
8use crate::display::transaction::{
9    format_transaction_details, format_transaction_list_by_account, format_transaction_register,
10};
11use crate::error::{EnvelopeError, EnvelopeResult};
12use crate::models::{Money, TransactionStatus};
13use crate::services::{
14    AccountService, CategoryService, CreateTransactionInput, PayeeService, TransactionFilter,
15    TransactionService,
16};
17use crate::storage::Storage;
18
19/// Transaction subcommands
20#[derive(Subcommand)]
21pub enum TransactionCommands {
22    /// Add a new transaction
23    Add {
24        /// Account name or ID
25        account: String,
26        /// Amount (e.g., "-50.00" for outflow, "100.00" for inflow)
27        amount: String,
28        /// Payee name
29        #[arg(short, long)]
30        payee: Option<String>,
31        /// Category name
32        #[arg(short, long)]
33        category: Option<String>,
34        /// Transaction date (YYYY-MM-DD), defaults to today
35        #[arg(short, long)]
36        date: Option<String>,
37        /// Memo
38        #[arg(short, long)]
39        memo: Option<String>,
40        /// Mark as cleared
41        #[arg(long)]
42        cleared: bool,
43        /// Auto-categorize based on payee history
44        #[arg(long)]
45        auto_categorize: bool,
46    },
47    /// List transactions
48    List {
49        /// Filter by account name or ID
50        #[arg(short, long)]
51        account: Option<String>,
52        /// Filter by category name
53        #[arg(short = 'C', long)]
54        category: Option<String>,
55        /// Number of transactions to show
56        #[arg(short, long, default_value = "20")]
57        limit: usize,
58        /// Start date (YYYY-MM-DD)
59        #[arg(long)]
60        from: Option<String>,
61        /// End date (YYYY-MM-DD)
62        #[arg(long)]
63        to: Option<String>,
64        /// Filter by status (pending, cleared, reconciled)
65        #[arg(long)]
66        status: Option<String>,
67    },
68    /// Show transaction details
69    Show {
70        /// Transaction ID
71        id: String,
72    },
73    /// Edit a transaction
74    Edit {
75        /// Transaction ID
76        id: String,
77        /// New amount
78        #[arg(short, long)]
79        amount: Option<String>,
80        /// New payee
81        #[arg(short, long)]
82        payee: Option<String>,
83        /// New category
84        #[arg(short, long)]
85        category: Option<String>,
86        /// New date
87        #[arg(short, long)]
88        date: Option<String>,
89        /// New memo
90        #[arg(short, long)]
91        memo: Option<String>,
92    },
93    /// Delete a transaction
94    Delete {
95        /// Transaction ID
96        id: String,
97        /// Skip confirmation
98        #[arg(short, long)]
99        force: bool,
100    },
101    /// Clear a transaction (mark as cleared)
102    Clear {
103        /// Transaction ID
104        id: String,
105    },
106    /// Unclear a transaction (mark as pending)
107    Unclear {
108        /// Transaction ID
109        id: String,
110    },
111    /// Unlock a reconciled transaction for editing
112    Unlock {
113        /// Transaction ID
114        id: String,
115    },
116}
117
118/// Handle a transaction command
119pub fn handle_transaction_command(
120    storage: &Storage,
121    cmd: TransactionCommands,
122) -> EnvelopeResult<()> {
123    let service = TransactionService::new(storage);
124    let account_service = AccountService::new(storage);
125    let category_service = CategoryService::new(storage);
126    let payee_service = PayeeService::new(storage);
127
128    match cmd {
129        TransactionCommands::Add {
130            account,
131            amount,
132            payee,
133            category,
134            date,
135            memo,
136            cleared,
137            auto_categorize,
138        } => {
139            // Find account
140            let account = account_service
141                .find(&account)?
142                .ok_or_else(|| EnvelopeError::account_not_found(&account))?;
143
144            // Parse amount
145            let amount = Money::parse(&amount).map_err(|e| {
146                EnvelopeError::Validation(format!(
147                    "Invalid amount format: '{}'. Use format like '-50.00' or '100'. Error: {}",
148                    amount, e
149                ))
150            })?;
151
152            // Parse date (default to today)
153            let date = if let Some(date_str) = date {
154                NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|_| {
155                    EnvelopeError::Validation(format!(
156                        "Invalid date format: '{}'. Use YYYY-MM-DD",
157                        date_str
158                    ))
159                })?
160            } else {
161                chrono::Local::now().date_naive()
162            };
163
164            // Find category
165            let mut category_id = if let Some(cat_name) = &category {
166                let cat = category_service
167                    .find_category(cat_name)?
168                    .ok_or_else(|| EnvelopeError::category_not_found(cat_name))?;
169                Some(cat.id)
170            } else {
171                None
172            };
173
174            // Auto-categorize from payee if requested
175            if auto_categorize && category_id.is_none() {
176                if let Some(payee_name) = &payee {
177                    category_id = payee_service.get_suggested_category(payee_name)?;
178                    if category_id.is_some() {
179                        println!("Auto-categorized based on payee history");
180                    }
181                }
182            }
183
184            let status = if cleared {
185                Some(TransactionStatus::Cleared)
186            } else {
187                None
188            };
189
190            let input = CreateTransactionInput {
191                account_id: account.id,
192                date,
193                amount,
194                payee_name: payee,
195                category_id,
196                memo,
197                status,
198            };
199
200            let txn = service.create(input)?;
201
202            // Learn from transaction (update payee category frequency)
203            service.learn_from_transaction(&txn)?;
204
205            println!("Created transaction:");
206            println!("  ID:       {}", txn.id);
207            println!("  Date:     {}", txn.date);
208            println!("  Amount:   {}", txn.amount);
209            if !txn.payee_name.is_empty() {
210                println!("  Payee:    {}", txn.payee_name);
211            }
212            if let Some(cat_id) = txn.category_id {
213                if let Some(cat) = category_service.get_category(cat_id)? {
214                    println!("  Category: {}", cat.name);
215                }
216            }
217            println!("  Status:   {}", txn.status);
218        }
219
220        TransactionCommands::List {
221            account,
222            category,
223            limit,
224            from,
225            to,
226            status,
227        } => {
228            let mut filter = TransactionFilter::new().limit(limit);
229
230            // Apply account filter
231            if let Some(acc_name) = &account {
232                let acc = account_service
233                    .find(acc_name)?
234                    .ok_or_else(|| EnvelopeError::account_not_found(acc_name))?;
235                filter = filter.account(acc.id);
236            }
237
238            // Apply category filter
239            if let Some(cat_name) = &category {
240                let cat = category_service
241                    .find_category(cat_name)?
242                    .ok_or_else(|| EnvelopeError::category_not_found(cat_name))?;
243                filter = filter.category(cat.id);
244            }
245
246            // Apply date range filter
247            if let Some(from_str) = from {
248                let from_date = NaiveDate::parse_from_str(&from_str, "%Y-%m-%d").map_err(|_| {
249                    EnvelopeError::Validation(format!(
250                        "Invalid date format: '{}'. Use YYYY-MM-DD",
251                        from_str
252                    ))
253                })?;
254                filter.start_date = Some(from_date);
255            }
256
257            if let Some(to_str) = to {
258                let to_date = NaiveDate::parse_from_str(&to_str, "%Y-%m-%d").map_err(|_| {
259                    EnvelopeError::Validation(format!(
260                        "Invalid date format: '{}'. Use YYYY-MM-DD",
261                        to_str
262                    ))
263                })?;
264                filter.end_date = Some(to_date);
265            }
266
267            // Apply status filter
268            if let Some(status_str) = status {
269                let status = match status_str.to_lowercase().as_str() {
270                    "pending" => TransactionStatus::Pending,
271                    "cleared" => TransactionStatus::Cleared,
272                    "reconciled" => TransactionStatus::Reconciled,
273                    _ => {
274                        return Err(EnvelopeError::Validation(format!(
275                            "Invalid status: '{}'. Use pending, cleared, or reconciled",
276                            status_str
277                        )))
278                    }
279                };
280                filter = filter.status(status);
281            }
282
283            let transactions = service.list(filter)?;
284
285            if let Some(acc_name) = &account {
286                if let Some(acc) = account_service.find(acc_name)? {
287                    print!(
288                        "{}",
289                        format_transaction_list_by_account(&transactions, &acc.name)
290                    );
291                } else {
292                    print!("{}", format_transaction_register(&transactions));
293                }
294            } else {
295                print!("{}", format_transaction_register(&transactions));
296            }
297
298            println!("\nShowing {} transactions", transactions.len());
299        }
300
301        TransactionCommands::Show { id } => {
302            let txn = service
303                .find(&id)?
304                .ok_or_else(|| EnvelopeError::transaction_not_found(&id))?;
305
306            let category_name = if let Some(cat_id) = txn.category_id {
307                category_service.get_category(cat_id)?.map(|c| c.name)
308            } else {
309                None
310            };
311
312            print!(
313                "{}",
314                format_transaction_details(&txn, category_name.as_deref())
315            );
316
317            // Show account name
318            if let Some(account) = account_service.get(txn.account_id)? {
319                println!("Account:     {}", account.name);
320            }
321        }
322
323        TransactionCommands::Edit {
324            id,
325            amount,
326            payee,
327            category,
328            date,
329            memo,
330        } => {
331            let txn = service
332                .find(&id)?
333                .ok_or_else(|| EnvelopeError::transaction_not_found(&id))?;
334
335            // Parse new values if provided
336            let new_amount = if let Some(amt_str) = amount {
337                Some(
338                    Money::parse(&amt_str)
339                        .map_err(|e| EnvelopeError::Validation(format!("Invalid amount: {}", e)))?,
340                )
341            } else {
342                None
343            };
344
345            let new_date = if let Some(date_str) = date {
346                Some(
347                    NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|_| {
348                        EnvelopeError::Validation(format!(
349                            "Invalid date format: '{}'. Use YYYY-MM-DD",
350                            date_str
351                        ))
352                    })?,
353                )
354            } else {
355                None
356            };
357
358            let new_category_id = if let Some(cat_name) = category {
359                if cat_name.is_empty() || cat_name.to_lowercase() == "none" {
360                    // Clear category
361                    Some(None)
362                } else {
363                    let cat = category_service
364                        .find_category(&cat_name)?
365                        .ok_or_else(|| EnvelopeError::category_not_found(&cat_name))?;
366                    Some(Some(cat.id))
367                }
368            } else {
369                None
370            };
371
372            let updated =
373                service.update(txn.id, new_date, new_amount, payee, new_category_id, memo)?;
374
375            println!("Updated transaction: {}", updated.id);
376            println!("  Date:   {}", updated.date);
377            println!("  Amount: {}", updated.amount);
378            if !updated.payee_name.is_empty() {
379                println!("  Payee:  {}", updated.payee_name);
380            }
381        }
382
383        TransactionCommands::Delete { id, force } => {
384            let txn = service
385                .find(&id)?
386                .ok_or_else(|| EnvelopeError::transaction_not_found(&id))?;
387
388            if !force {
389                println!("About to delete transaction:");
390                println!("  Date:   {}", txn.date);
391                println!("  Amount: {}", txn.amount);
392                println!("  Payee:  {}", txn.payee_name);
393                println!();
394                println!("Use --force to confirm deletion");
395                return Ok(());
396            }
397
398            let deleted = service.delete(txn.id)?;
399            println!(
400                "Deleted transaction: {} ({} {})",
401                deleted.id, deleted.date, deleted.payee_name
402            );
403        }
404
405        TransactionCommands::Clear { id } => {
406            let txn = service
407                .find(&id)?
408                .ok_or_else(|| EnvelopeError::transaction_not_found(&id))?;
409
410            let cleared = service.clear(txn.id)?;
411            println!(
412                "Cleared transaction: {} ({})",
413                cleared.id, cleared.payee_name
414            );
415        }
416
417        TransactionCommands::Unclear { id } => {
418            let txn = service
419                .find(&id)?
420                .ok_or_else(|| EnvelopeError::transaction_not_found(&id))?;
421
422            let uncleared = service.unclear(txn.id)?;
423            println!(
424                "Uncleared transaction: {} ({})",
425                uncleared.id, uncleared.payee_name
426            );
427        }
428
429        TransactionCommands::Unlock { id } => {
430            let txn = service
431                .find(&id)?
432                .ok_or_else(|| EnvelopeError::transaction_not_found(&id))?;
433
434            let unlocked = service.unlock(txn.id)?;
435            println!(
436                "Unlocked transaction: {} ({}) - now marked as Cleared",
437                unlocked.id, unlocked.payee_name
438            );
439            println!("WARNING: This transaction was previously reconciled.");
440            println!("         Editing it may cause discrepancies with your bank statement.");
441        }
442    }
443
444    Ok(())
445}