1use 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#[derive(Subcommand)]
21pub enum TransactionCommands {
22 Add {
24 account: String,
26 amount: String,
28 #[arg(short, long)]
30 payee: Option<String>,
31 #[arg(short, long)]
33 category: Option<String>,
34 #[arg(short, long)]
36 date: Option<String>,
37 #[arg(short, long)]
39 memo: Option<String>,
40 #[arg(long)]
42 cleared: bool,
43 #[arg(long)]
45 auto_categorize: bool,
46 },
47 List {
49 #[arg(short, long)]
51 account: Option<String>,
52 #[arg(short = 'C', long)]
54 category: Option<String>,
55 #[arg(short, long, default_value = "20")]
57 limit: usize,
58 #[arg(long)]
60 from: Option<String>,
61 #[arg(long)]
63 to: Option<String>,
64 #[arg(long)]
66 status: Option<String>,
67 },
68 Show {
70 id: String,
72 },
73 Edit {
75 id: String,
77 #[arg(short, long)]
79 amount: Option<String>,
80 #[arg(short, long)]
82 payee: Option<String>,
83 #[arg(short, long)]
85 category: Option<String>,
86 #[arg(short, long)]
88 date: Option<String>,
89 #[arg(short, long)]
91 memo: Option<String>,
92 },
93 Delete {
95 id: String,
97 #[arg(short, long)]
99 force: bool,
100 },
101 Clear {
103 id: String,
105 },
106 Unclear {
108 id: String,
110 },
111 Unlock {
113 id: String,
115 },
116}
117
118pub 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 let account = account_service
141 .find(&account)?
142 .ok_or_else(|| EnvelopeError::account_not_found(&account))?;
143
144 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 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 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 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 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 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 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 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 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 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 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 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}