envelope_cli/cli/
reconcile.rs

1//! Reconciliation CLI commands
2//!
3//! Implements CLI commands for account reconciliation workflow.
4
5use chrono::NaiveDate;
6use clap::Subcommand;
7
8use crate::error::{EnvelopeError, EnvelopeResult};
9use crate::models::Money;
10use crate::services::{AccountService, CategoryService, ReconciliationService};
11use crate::storage::Storage;
12
13/// Reconciliation subcommands
14#[derive(Subcommand)]
15pub enum ReconcileCommands {
16    /// Start reconciliation for an account
17    Start {
18        /// Account name or ID
19        account: String,
20        /// Statement ending balance (e.g., "1234.56")
21        balance: String,
22        /// Statement date (YYYY-MM-DD), defaults to today
23        #[arg(short, long)]
24        date: Option<String>,
25    },
26    /// Show reconciliation status for an account
27    Status {
28        /// Account name or ID
29        account: String,
30        /// Statement ending balance (e.g., "1234.56")
31        balance: String,
32        /// Statement date (YYYY-MM-DD), defaults to today
33        #[arg(short, long)]
34        date: Option<String>,
35    },
36    /// Mark a transaction as cleared during reconciliation
37    Clear {
38        /// Transaction ID
39        id: String,
40    },
41    /// Mark a transaction as pending during reconciliation
42    Unclear {
43        /// Transaction ID
44        id: String,
45    },
46    /// Complete reconciliation (requires difference to be zero)
47    Complete {
48        /// Account name or ID
49        account: String,
50        /// Statement ending balance (e.g., "1234.56")
51        balance: String,
52        /// Statement date (YYYY-MM-DD), defaults to today
53        #[arg(short, long)]
54        date: Option<String>,
55    },
56    /// Complete reconciliation with adjustment for discrepancies
57    Adjust {
58        /// Account name or ID
59        account: String,
60        /// Statement ending balance (e.g., "1234.56")
61        balance: String,
62        /// Statement date (YYYY-MM-DD), defaults to today
63        #[arg(short, long)]
64        date: Option<String>,
65        /// Category for the adjustment transaction
66        #[arg(short, long)]
67        category: Option<String>,
68    },
69}
70
71/// Handle a reconcile command
72pub fn handle_reconcile_command(storage: &Storage, cmd: ReconcileCommands) -> EnvelopeResult<()> {
73    let service = ReconciliationService::new(storage);
74    let account_service = AccountService::new(storage);
75    let category_service = CategoryService::new(storage);
76
77    match cmd {
78        ReconcileCommands::Start {
79            account,
80            balance,
81            date,
82        } => {
83            let account = account_service
84                .find(&account)?
85                .ok_or_else(|| EnvelopeError::account_not_found(&account))?;
86
87            let statement_balance = Money::parse(&balance).map_err(|e| {
88                EnvelopeError::Validation(format!(
89                    "Invalid balance format: '{}'. Use format like '1234.56'. Error: {}",
90                    balance, e
91                ))
92            })?;
93
94            let statement_date = parse_date_or_today(date.as_deref())?;
95
96            let session = service.start(account.id, statement_date, statement_balance)?;
97            let summary = service.get_summary(&session)?;
98
99            println!("Reconciliation started for: {}", account.name);
100            println!("Statement Date: {}", statement_date);
101            println!("Statement Balance: {}", statement_balance);
102            println!();
103            println!("Current Status:");
104            println!(
105                "  Starting reconciled balance: {}",
106                session.starting_cleared_balance
107            );
108            println!(
109                "  Current cleared balance:     {}",
110                summary.current_cleared_balance
111            );
112            println!("  Difference:                  {}", summary.difference);
113            println!();
114            println!("Transactions:");
115            println!(
116                "  Cleared (ready to reconcile): {}",
117                summary.cleared_transactions.len()
118            );
119            println!(
120                "  Pending (uncleared):          {}",
121                summary.uncleared_transactions.len()
122            );
123
124            if !summary.cleared_transactions.is_empty() {
125                println!();
126                println!("Cleared transactions:");
127                for txn in &summary.cleared_transactions {
128                    println!(
129                        "  {} {} {:>12}  {}",
130                        txn.id.to_string().chars().take(8).collect::<String>(),
131                        txn.date,
132                        txn.amount,
133                        txn.payee_name
134                    );
135                }
136            }
137
138            if !summary.uncleared_transactions.is_empty() {
139                println!();
140                println!("Pending transactions:");
141                for txn in &summary.uncleared_transactions {
142                    println!(
143                        "  {} {} {:>12}  {}",
144                        txn.id.to_string().chars().take(8).collect::<String>(),
145                        txn.date,
146                        txn.amount,
147                        txn.payee_name
148                    );
149                }
150            }
151
152            println!();
153            if summary.can_complete {
154                println!("Ready to complete! Run 'envelope reconcile complete' to finish.");
155            } else {
156                println!(
157                    "Difference is {}. Clear/unclear transactions until difference is $0.00",
158                    summary.difference
159                );
160                println!("Or use 'envelope reconcile adjust' to create an adjustment transaction.");
161            }
162        }
163
164        ReconcileCommands::Status {
165            account,
166            balance,
167            date,
168        } => {
169            let account = account_service
170                .find(&account)?
171                .ok_or_else(|| EnvelopeError::account_not_found(&account))?;
172
173            let statement_balance = Money::parse(&balance)
174                .map_err(|e| EnvelopeError::Validation(format!("Invalid balance: {}", e)))?;
175
176            let statement_date = parse_date_or_today(date.as_deref())?;
177
178            let session = service.start(account.id, statement_date, statement_balance)?;
179            let summary = service.get_summary(&session)?;
180
181            println!("Reconciliation Status: {}", account.name);
182            println!("{}", "=".repeat(40));
183            println!();
184            println!("Statement balance:     {}", statement_balance);
185            println!("Current cleared:       {}", summary.current_cleared_balance);
186            println!("Difference:            {}", summary.difference);
187            println!();
188
189            if let Some(last_date) = account.last_reconciled_date {
190                println!("Last reconciliation:   {}", last_date);
191                if let Some(last_balance) = account.last_reconciled_balance {
192                    println!("Last reconciled balance: {}", last_balance);
193                }
194            } else {
195                println!("Last reconciliation:   Never");
196            }
197        }
198
199        ReconcileCommands::Clear { id } => {
200            let txn = service.clear_transaction(id.parse().map_err(|_| {
201                EnvelopeError::Validation(format!("Invalid transaction ID: {}", id))
202            })?)?;
203
204            println!("Cleared: {} {} {}", txn.date, txn.payee_name, txn.amount);
205        }
206
207        ReconcileCommands::Unclear { id } => {
208            let txn = service.unclear_transaction(id.parse().map_err(|_| {
209                EnvelopeError::Validation(format!("Invalid transaction ID: {}", id))
210            })?)?;
211
212            println!("Uncleared: {} {} {}", txn.date, txn.payee_name, txn.amount);
213        }
214
215        ReconcileCommands::Complete {
216            account,
217            balance,
218            date,
219        } => {
220            let account = account_service
221                .find(&account)?
222                .ok_or_else(|| EnvelopeError::account_not_found(&account))?;
223
224            let statement_balance = Money::parse(&balance)
225                .map_err(|e| EnvelopeError::Validation(format!("Invalid balance: {}", e)))?;
226
227            let statement_date = parse_date_or_today(date.as_deref())?;
228
229            let session = service.start(account.id, statement_date, statement_balance)?;
230            let result = service.complete(&session)?;
231
232            println!("Reconciliation complete!");
233            println!("  Account: {}", account.name);
234            println!("  Statement date: {}", statement_date);
235            println!("  Statement balance: {}", statement_balance);
236            println!(
237                "  Transactions reconciled: {}",
238                result.transactions_reconciled
239            );
240        }
241
242        ReconcileCommands::Adjust {
243            account,
244            balance,
245            date,
246            category,
247        } => {
248            let account = account_service
249                .find(&account)?
250                .ok_or_else(|| EnvelopeError::account_not_found(&account))?;
251
252            let statement_balance = Money::parse(&balance)
253                .map_err(|e| EnvelopeError::Validation(format!("Invalid balance: {}", e)))?;
254
255            let statement_date = parse_date_or_today(date.as_deref())?;
256
257            let category_id = if let Some(cat_name) = category {
258                let cat = category_service
259                    .find_category(&cat_name)?
260                    .ok_or_else(|| EnvelopeError::category_not_found(&cat_name))?;
261                Some(cat.id)
262            } else {
263                None
264            };
265
266            let session = service.start(account.id, statement_date, statement_balance)?;
267            let summary = service.get_summary(&session)?;
268
269            println!(
270                "Creating adjustment transaction for: {}",
271                summary.difference
272            );
273
274            let result = service.complete_with_adjustment(&session, category_id)?;
275
276            println!();
277            println!("Reconciliation complete with adjustment!");
278            println!("  Account: {}", account.name);
279            println!("  Statement date: {}", statement_date);
280            println!("  Statement balance: {}", statement_balance);
281            println!(
282                "  Transactions reconciled: {}",
283                result.transactions_reconciled
284            );
285            if result.adjustment_created {
286                println!(
287                    "  Adjustment created: {}",
288                    result.adjustment_amount.unwrap()
289                );
290            }
291        }
292    }
293
294    Ok(())
295}
296
297/// Parse a date string or return today's date
298fn parse_date_or_today(date_str: Option<&str>) -> EnvelopeResult<NaiveDate> {
299    if let Some(date_str) = date_str {
300        NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|_| {
301            EnvelopeError::Validation(format!(
302                "Invalid date format: '{}'. Use YYYY-MM-DD",
303                date_str
304            ))
305        })
306    } else {
307        Ok(chrono::Local::now().date_naive())
308    }
309}