1
use finance::price::Price;
2
use finance::split::Split;
3
use num_rational::Rational64;
4
use server::command::{
5
    Argument, CmdError, CmdResult, FinanceEntity, account::CreateAccount,
6
    account::GetAccountCommodities, account::GetBalance, account::ListAccounts,
7
    commodity::CreateCommodity, commodity::GetCommodity, commodity::ListCommodities,
8
    config::GetConfig, config::GetVersion, config::SelectColumn, config::SetConfig,
9
    transaction::CreateTransaction, transaction::ListTransactions,
10
};
11
use sqlx::types::Uuid;
12
use sqlx::types::chrono::Utc;
13
use std::collections::HashMap;
14
use std::fmt::Debug;
15
use std::future::Future;
16
use std::pin::Pin;
17
use thiserror::Error;
18

            
19
// Single trait that returns a Future
20
pub trait CliRunnable: Debug + Send {
21
    fn run<'a>(
22
        &'a self,
23
        args: &'a HashMap<&str, &Argument>,
24
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>;
25
}
26

            
27
#[derive(Debug)]
28
pub struct ArgumentNode {
29
    pub name: String,
30
    pub comment: String,
31
    pub completions: Option<Box<dyn CliRunnable>>,
32
}
33

            
34
#[derive(Debug)]
35
pub struct CommandNode {
36
    pub name: String,
37
    pub comment: String,
38
    pub command: Option<Box<dyn CliRunnable>>,
39
    pub subcommands: Vec<CommandNode>,
40
    pub arguments: Vec<ArgumentNode>,
41
}
42

            
43
#[derive(Debug, Error)]
44
pub enum CommandError {
45
    #[error("No such command: {0}")]
46
    Command(String),
47
    #[error("Arguments error: {0}")]
48
    Argument(String),
49
    #[error("Execution: {0}")]
50
    Execution(#[from] CmdError),
51
}
52

            
53
pub trait CliCommand: Debug + Send {
54
    fn node() -> CommandNode;
55
}
56

            
57
#[derive(Debug)]
58
pub struct CliGetConfig;
59

            
60
impl CliRunnable for CliGetConfig {
61
    fn run<'a>(
62
        &'a self,
63
        args: &'a HashMap<&str, &Argument>,
64
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
65
        log::trace!("Running get with {args:?}");
66
        Box::pin(async move {
67
            if let Some(Argument::String(name)) = args.get("name") {
68
                Ok(GetConfig::new().name(name.clone()).run().await?)
69
            } else {
70
                Err(CommandError::Argument("No field name provided".to_string()))
71
            }
72
        })
73
    }
74
}
75

            
76
impl CliCommand for CliGetConfig {
77
    fn node() -> CommandNode {
78
        CommandNode {
79
            name: "get".to_string(),
80
            command: Some(Box::new(CliGetConfig)),
81
            comment: "Print the value from config".to_string(),
82
            subcommands: vec![],
83
            arguments: vec![
84
                ArgumentNode {
85
                    name: "name".to_string(),
86
                    comment: "Variable name".to_string(),
87
                    completions: None,
88
                },
89
                ArgumentNode {
90
                    name: "print".to_string(),
91
                    comment: "Print return value".to_string(),
92
                    completions: None,
93
                },
94
            ],
95
        }
96
    }
97
}
98

            
99
#[derive(Debug)]
100
pub struct CliSetConfig;
101

            
102
impl CliRunnable for CliSetConfig {
103
    fn run<'a>(
104
        &'a self,
105
        args: &'a HashMap<&str, &Argument>,
106
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
107
        log::debug!("Running set with {args:?}");
108
        Box::pin(async move {
109
            match (args.get("name"), args.get("value")) {
110
                (Some(Argument::String(name)), Some(Argument::String(value))) => {
111
                    Ok(SetConfig::new()
112
                        .name(name.clone())
113
                        .value(value.clone())
114
                        .run()
115
                        .await?)
116
                }
117
                _ => Err(CommandError::Argument(
118
                    "No field name or value provided".to_string(),
119
                )),
120
            }
121
        })
122
    }
123
}
124

            
125
impl CliCommand for CliSetConfig {
126
    fn node() -> CommandNode {
127
        CommandNode {
128
            name: "set".to_string(),
129
            command: Some(Box::new(CliSetConfig)),
130
            comment: "Set the value in config".to_string(),
131
            subcommands: vec![],
132
            arguments: vec![
133
                ArgumentNode {
134
                    name: "name".to_string(),
135
                    comment: "Variable name".to_string(),
136
                    completions: None,
137
                },
138
                ArgumentNode {
139
                    name: "value".to_string(),
140
                    comment: "Value to set".to_string(),
141
                    completions: None,
142
                },
143
            ],
144
        }
145
    }
146
}
147

            
148
#[derive(Debug)]
149
pub struct CliVersion;
150

            
151
impl CliRunnable for CliVersion {
152
    fn run<'a>(
153
        &'a self,
154
        _args: &'a HashMap<&str, &Argument>,
155
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
156
        Box::pin(async move { Ok(GetVersion::new().run().await?) })
157
    }
158
}
159

            
160
impl CliCommand for CliVersion {
161
    fn node() -> CommandNode {
162
        CommandNode {
163
            name: "version".to_string(),
164
            command: Some(Box::new(CliVersion)),
165
            comment: "Print the software version".to_string(),
166
            subcommands: vec![],
167
            arguments: vec![],
168
        }
169
    }
170
}
171

            
172
#[derive(Debug)]
173
pub struct CliSelectColumn;
174

            
175
impl CliRunnable for CliSelectColumn {
176
    fn run<'a>(
177
        &'a self,
178
        args: &'a HashMap<&str, &Argument>,
179
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
180
        Box::pin(async move {
181
            match (args.get("field"), args.get("table")) {
182
                (Some(Argument::String(field)), Some(Argument::String(table))) => {
183
                    Ok(SelectColumn::new()
184
                        .field(field.clone())
185
                        .table(table.clone())
186
                        .run()
187
                        .await?)
188
                }
189
                _ => Err(CommandError::Argument(
190
                    "No column or table provided".to_string(),
191
                )),
192
            }
193
        })
194
    }
195
}
196

            
197
impl CliCommand for CliSelectColumn {
198
    fn node() -> CommandNode {
199
        CommandNode {
200
            name: "selcol".to_string(),
201
            command: Some(Box::new(CliSelectColumn)),
202
            comment: "Raw select of SQL table".to_string(),
203
            subcommands: vec![],
204
            arguments: vec![
205
                ArgumentNode {
206
                    name: "field".to_string(),
207
                    comment: "Field name".to_string(),
208
                    completions: None,
209
                },
210
                ArgumentNode {
211
                    name: "table".to_string(),
212
                    comment: "Table name".to_string(),
213
                    completions: None,
214
                },
215
            ],
216
        }
217
    }
218
}
219

            
220
#[derive(Debug)]
221
pub struct CliCommodityCreate;
222

            
223
impl CliRunnable for CliCommodityCreate {
224
    fn run<'a>(
225
        &'a self,
226
        args: &'a HashMap<&str, &Argument>,
227
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
228
        Box::pin(async move {
229
            match (
230
                args.get("fraction"),
231
                args.get("symbol"),
232
                args.get("name"),
233
                args.get("user_id"),
234
            ) {
235
                (
236
                    Some(Argument::Rational(fraction)),
237
                    Some(Argument::String(symbol)),
238
                    Some(Argument::String(name)),
239
                    Some(Argument::Uuid(user_id)),
240
                ) => Ok(CreateCommodity::new()
241
                    .fraction(*fraction)
242
                    .symbol(symbol.clone())
243
                    .name(name.clone())
244
                    .user_id(*user_id)
245
                    .run()
246
                    .await?),
247
                _ => Err(CommandError::Argument(
248
                    "Provide fraction, symbol, name, user_id".to_string(),
249
                )),
250
            }
251
        })
252
    }
253
}
254

            
255
impl CliCommand for CliCommodityCreate {
256
    fn node() -> CommandNode {
257
        CommandNode {
258
            name: "create".to_string(),
259
            command: Some(Box::new(CliCommodityCreate)),
260
            comment: "Create new commodity".to_string(),
261
            subcommands: vec![],
262
            arguments: vec![
263
                ArgumentNode {
264
                    name: "fraction".to_string(),
265
                    comment: "The maximal divisor of the commodity".to_string(),
266
                    completions: None,
267
                },
268
                ArgumentNode {
269
                    name: "symbol".to_string(),
270
                    comment: "The abbreviation (or symbol) of the commodity".to_string(),
271
                    completions: None,
272
                },
273
                ArgumentNode {
274
                    name: "name".to_string(),
275
                    comment: "Human-readable name of commodity".to_string(),
276
                    completions: None,
277
                },
278
            ],
279
        }
280
    }
281
}
282

            
283
#[derive(Debug)]
284
pub struct CliCommodityList;
285

            
286
impl CliRunnable for CliCommodityList {
287
    fn run<'a>(
288
        &'a self,
289
        args: &'a HashMap<&str, &Argument>,
290
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
291
        Box::pin(async move {
292
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
293
                *user_id
294
            } else {
295
                return Err(CommandError::Execution(CmdError::Args(
296
                    "user_id is required".to_string(),
297
                )));
298
            };
299

            
300
            let result = ListCommodities::new().user_id(user_id).run().await?;
301
            if let Some(CmdResult::TaggedEntities(entities)) = result {
302
                let mut result: Vec<String> = vec![];
303
                for (_, tags) in entities {
304
                    if let (FinanceEntity::Tag(s), FinanceEntity::Tag(n)) =
305
                        (&tags["symbol"], &tags["name"])
306
                    {
307
                        result.push(format!("{} - {}", s.tag_value, n.tag_value));
308
                    };
309
                }
310
                Ok(Some(CmdResult::Lines(result)))
311
            } else {
312
                Ok(None)
313
            }
314
        })
315
    }
316
}
317

            
318
impl CliCommand for CliCommodityList {
319
    fn node() -> CommandNode {
320
        CommandNode {
321
            name: "list".to_string(),
322
            command: Some(Box::new(CliCommodityList)),
323
            comment: "List all commodities".to_string(),
324
            subcommands: vec![],
325
            arguments: vec![],
326
        }
327
    }
328
}
329

            
330
#[derive(Debug)]
331
pub struct CliCommodityCompletion;
332

            
333
impl CliRunnable for CliCommodityCompletion {
334
    fn run<'a>(
335
        &'a self,
336
        args: &'a HashMap<&str, &Argument>,
337
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
338
        Box::pin(async move {
339
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
340
                *user_id
341
            } else {
342
                return Err(CommandError::Execution(CmdError::Args(
343
                    "user_id is required".to_string(),
344
                )));
345
            };
346

            
347
            Ok(ListCommodities::new().user_id(user_id).run().await?)
348
        })
349
    }
350
}
351

            
352
#[derive(Debug)]
353
pub struct CliAccountCreate;
354

            
355
impl CliRunnable for CliAccountCreate {
356
    fn run<'a>(
357
        &'a self,
358
        args: &'a HashMap<&str, &Argument>,
359
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
360
        Box::pin(async move {
361
            let name = if let Some(Argument::String(name)) = args.get("name") {
362
                name.clone()
363
            } else {
364
                return Err(CommandError::Execution(CmdError::Args(
365
                    "name is required".to_string(),
366
                )));
367
            };
368

            
369
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
370
                *user_id
371
            } else {
372
                return Err(CommandError::Execution(CmdError::Args(
373
                    "user_id is required".to_string(),
374
                )));
375
            };
376

            
377
            let mut builder = CreateAccount::new().name(name).user_id(user_id);
378

            
379
            // Handle optional parent parameter
380
            if let Some(Argument::Uuid(parent_id)) = args.get("parent") {
381
                builder = builder.parent(*parent_id);
382
            }
383

            
384
            Ok(builder.run().await?)
385
        })
386
    }
387
}
388

            
389
impl CliCommand for CliAccountCreate {
390
    fn node() -> CommandNode {
391
        CommandNode {
392
            name: "create".to_string(),
393
            command: Some(Box::new(CliAccountCreate)),
394
            comment: "Create new account".to_string(),
395
            subcommands: vec![],
396
            arguments: vec![
397
                ArgumentNode {
398
                    name: "name".to_string(),
399
                    comment: "Name of the account".to_string(),
400
                    completions: None,
401
                },
402
                ArgumentNode {
403
                    name: "parent".to_string(),
404
                    comment: "Optional parent account".to_string(),
405
                    completions: None,
406
                },
407
            ],
408
        }
409
    }
410
}
411

            
412
#[derive(Debug)]
413
pub struct CliAccountList;
414

            
415
impl CliRunnable for CliAccountList {
416
    fn run<'a>(
417
        &'a self,
418
        args: &'a HashMap<&str, &Argument>,
419
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
420
        Box::pin(async move {
421
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
422
                *user_id
423
            } else {
424
                return Err(CommandError::Execution(CmdError::Args(
425
                    "user_id is required".to_string(),
426
                )));
427
            };
428

            
429
            let result = ListAccounts::new().user_id(user_id).run().await?;
430
            if let Some(CmdResult::TaggedEntities(entities)) = result {
431
                let mut result: Vec<String> = vec![];
432
                for (_, tags) in entities {
433
                    if let FinanceEntity::Tag(n) = &tags["name"] {
434
                        result.push(n.tag_value.clone());
435
                    };
436
                }
437
                Ok(Some(CmdResult::Lines(result)))
438
            } else {
439
                Ok(None)
440
            }
441
        })
442
    }
443
}
444

            
445
impl CliCommand for CliAccountList {
446
    fn node() -> CommandNode {
447
        CommandNode {
448
            name: "list".to_string(),
449
            command: Some(Box::new(CliAccountList)),
450
            comment: "List all accounts".to_string(),
451
            subcommands: vec![],
452
            arguments: vec![],
453
        }
454
    }
455
}
456

            
457
#[derive(Debug)]
458
pub struct CliAccountCompletion;
459

            
460
impl CliRunnable for CliAccountCompletion {
461
    fn run<'a>(
462
        &'a self,
463
        args: &'a HashMap<&str, &Argument>,
464
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
465
        Box::pin(async move {
466
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
467
                *user_id
468
            } else {
469
                return Err(CommandError::Execution(CmdError::Args(
470
                    "user_id is required".to_string(),
471
                )));
472
            };
473

            
474
            Ok(ListAccounts::new().user_id(user_id).run().await?)
475
        })
476
    }
477
}
478

            
479
#[derive(Debug)]
480
pub struct CliTransactionCreate;
481

            
482
impl CliRunnable for CliTransactionCreate {
483
    fn run<'a>(
484
        &'a self,
485
        args: &'a HashMap<&str, &Argument>,
486
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
487
        Box::pin(async move {
488
            let from_account = if let Some(Argument::Uuid(from)) = args.get("from") {
489
                from
490
            } else {
491
                return Err(CommandError::Argument(
492
                    "from account not provided".to_string(),
493
                ));
494
            };
495

            
496
            let to_account = if let Some(Argument::Uuid(to)) = args.get("to") {
497
                to
498
            } else {
499
                return Err(CommandError::Argument(
500
                    "to account not provided".to_string(),
501
                ));
502
            };
503

            
504
            let value = if let Some(Argument::Rational(val)) = args.get("value") {
505
                val
506
            } else {
507
                return Err(CommandError::Argument("value not provided".to_string()));
508
            };
509

            
510
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
511
                *user_id
512
            } else {
513
                return Err(CommandError::Argument("User ID is required".to_string()));
514
            };
515

            
516
            let from_currency =
517
                if let Some(Argument::Uuid(from_currency)) = args.get("from_currency") {
518
                    *from_currency
519
                } else {
520
                    return Err(CommandError::Argument(
521
                        "from_currency is required".to_string(),
522
                    ));
523
                };
524

            
525
            let to_currency = if let Some(Argument::Uuid(to_currency)) = args.get("to_currency") {
526
                *to_currency
527
            } else {
528
                return Err(CommandError::Argument(
529
                    "to_currency is required".to_string(),
530
                ));
531
            };
532

            
533
            let tx_id = Uuid::new_v4();
534
            let now = Utc::now();
535
            let from_split_id = Uuid::new_v4();
536
            let to_split_id = Uuid::new_v4();
537

            
538
            // Check if currencies differ - if so, we need to_amount for conversion
539
            let currencies_differ = from_currency != to_currency;
540
            let to_amount = if currencies_differ {
541
                if let Some(Argument::Rational(to_val)) = args.get("to_amount") {
542
                    *to_val
543
                } else {
544
                    return Err(CommandError::Argument(
545
                        "to_amount is required when currencies differ".to_string(),
546
                    ));
547
                }
548
            } else {
549
                *value
550
            };
551

            
552
            // Get commodity fractions for precision validation
553
            let from_commodity_result = GetCommodity::new()
554
                .user_id(user_id)
555
                .commodity_id(from_currency)
556
                .run()
557
                .await?;
558

            
559
            let to_commodity_result = GetCommodity::new()
560
                .user_id(user_id)
561
                .commodity_id(to_currency)
562
                .run()
563
                .await?;
564

            
565
            let (from_fraction, to_fraction) = match (from_commodity_result, to_commodity_result) {
566
                (
567
                    Some(CmdResult::TaggedEntities(from_entities)),
568
                    Some(CmdResult::TaggedEntities(to_entities)),
569
                ) => {
570
                    let from_fraction =
571
                        if let Some((FinanceEntity::Commodity(c), _)) = from_entities.first() {
572
                            c.fraction
573
                        } else {
574
                            return Err(CommandError::Execution(CmdError::Args(
575
                                "From commodity not found".to_string(),
576
                            )));
577
                        };
578

            
579
                    let to_fraction =
580
                        if let Some((FinanceEntity::Commodity(c), _)) = to_entities.first() {
581
                            c.fraction
582
                        } else {
583
                            return Err(CommandError::Execution(CmdError::Args(
584
                                "To commodity not found".to_string(),
585
                            )));
586
                        };
587

            
588
                    (from_fraction, to_fraction)
589
                }
590
                _ => {
591
                    return Err(CommandError::Execution(CmdError::Args(
592
                        "Could not retrieve commodity information".to_string(),
593
                    )));
594
                }
595
            };
596

            
597
            // Create splits
598
            let split1 = Split {
599
                id: from_split_id,
600
                tx_id,
601
                account_id: *from_account,
602
                commodity_id: from_currency,
603
                value_num: -*value.numer(),
604
                value_denom: *value.denom(),
605
                reconcile_state: None,
606
                reconcile_date: None,
607
                lot_id: None,
608
            };
609

            
610
            let split2 = Split {
611
                id: to_split_id,
612
                tx_id,
613
                account_id: *to_account,
614
                commodity_id: to_currency,
615
                value_num: *to_amount.numer(),
616
                value_denom: *to_amount.denom(),
617
                reconcile_state: None,
618
                reconcile_date: None,
619
                lot_id: None,
620
            };
621

            
622
            let note = if let Some(Argument::String(note)) = args.get("note") {
623
                Some(note)
624
            } else {
625
                None
626
            };
627

            
628
            let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
629
            let mut prices = vec![];
630

            
631
            // Create price conversion if currencies differ
632
            if currencies_differ {
633
                let price = Price {
634
                    id: Uuid::new_v4(),
635
                    date: now,
636
                    commodity_id: to_currency,
637
                    currency_id: from_currency,
638
                    commodity_split: Some(to_split_id),
639
                    currency_split: Some(from_split_id),
640
                    value_num: *value.numer(),
641
                    value_denom: *to_amount.numer(),
642
                };
643
                prices.push(FinanceEntity::Price(price));
644
            }
645

            
646
            let mut cmd = CreateTransaction::new()
647
                .user_id(user_id)
648
                .splits(splits)
649
                .id(tx_id)
650
                .post_date(now)
651
                .enter_date(now);
652

            
653
            if !prices.is_empty() {
654
                cmd = cmd.prices(prices);
655
            }
656

            
657
            if let Some(note) = note {
658
                cmd = cmd.note(note.to_string());
659
            }
660

            
661
            Ok(cmd.run().await?)
662
        })
663
    }
664
}
665

            
666
impl CliCommand for CliTransactionCreate {
667
    fn node() -> CommandNode {
668
        CommandNode {
669
            name: "create".to_string(),
670
            command: Some(Box::new(CliTransactionCreate)),
671
            comment: "Create new transaction".to_string(),
672
            subcommands: vec![],
673
            arguments: vec![
674
                ArgumentNode {
675
                    name: "from".to_string(),
676
                    comment: "Source account".to_string(),
677
                    completions: Some(Box::new(CliAccountCompletion)),
678
                },
679
                ArgumentNode {
680
                    name: "to".to_string(),
681
                    comment: "Destination account".to_string(),
682
                    completions: Some(Box::new(CliAccountCompletion)),
683
                },
684
                ArgumentNode {
685
                    name: "from_currency".to_string(),
686
                    comment: "Currency for the source transaction".to_string(),
687
                    completions: Some(Box::new(CliCommodityCompletion)),
688
                },
689
                ArgumentNode {
690
                    name: "to_currency".to_string(),
691
                    comment: "Currency for the destination transaction".to_string(),
692
                    completions: Some(Box::new(CliCommodityCompletion)),
693
                },
694
                ArgumentNode {
695
                    name: "value".to_string(),
696
                    comment: "Transaction amount (from account)".to_string(),
697
                    completions: None,
698
                },
699
                ArgumentNode {
700
                    name: "to_amount".to_string(),
701
                    comment: "Transaction amount (to account, required when currencies differ)"
702
                        .to_string(),
703
                    completions: None,
704
                },
705
                ArgumentNode {
706
                    name: "note".to_string(),
707
                    comment: "Text memo for transaction".to_string(),
708
                    completions: None,
709
                },
710
            ],
711
        }
712
    }
713
}
714

            
715
#[derive(Debug)]
716
pub struct CliTransactionList;
717

            
718
impl CliRunnable for CliTransactionList {
719
    fn run<'a>(
720
        &'a self,
721
        args: &'a HashMap<&str, &Argument>,
722
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
723
        Box::pin(async move {
724
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
725
                *user_id
726
            } else {
727
                return Err(CommandError::Execution(CmdError::Args(
728
                    "user_id is required".to_string(),
729
                )));
730
            };
731

            
732
            let mut cmd = ListTransactions::new().user_id(user_id);
733

            
734
            // Add optional account filter if provided
735
            if let Some(Argument::Uuid(account_id)) = args.get("account") {
736
                cmd = cmd.account(*account_id);
737
            }
738

            
739
            let result = cmd.run().await?;
740
            if let Some(CmdResult::TaggedEntities(entities)) = result {
741
                let mut result: Vec<String> = vec![];
742
                for (entity, tags) in entities {
743
                    if let FinanceEntity::Transaction(tx) = entity {
744
                        result.push(format!(
745
                            "{} - {}",
746
                            if let Some(FinanceEntity::Tag(note)) = tags.get("note") {
747
                                note.tag_value.clone()
748
                            } else {
749
                                tx.id.to_string()
750
                            },
751
                            tx.post_date
752
                        ));
753
                    }
754
                }
755
                Ok(Some(CmdResult::Lines(result)))
756
            } else {
757
                Ok(None)
758
            }
759
        })
760
    }
761
}
762

            
763
impl CliCommand for CliTransactionList {
764
    fn node() -> CommandNode {
765
        CommandNode {
766
            name: "list".to_string(),
767
            command: Some(Box::new(CliTransactionList)),
768
            comment: "List all transactions".to_string(),
769
            subcommands: vec![],
770
            arguments: vec![ArgumentNode {
771
                name: "account".to_string(),
772
                comment: "Optional account to filter by".to_string(),
773
                completions: Some(Box::new(CliAccountCompletion)),
774
            }],
775
        }
776
    }
777
}
778

            
779
async fn get_cli_balance_with_currency(
780
    account_id: Uuid,
781
    user_id: Uuid,
782
) -> Result<(Rational64, String, String), CmdError> {
783
    // First get the commodity information for this account
784
    let commodities_result = GetAccountCommodities::new()
785
        .user_id(user_id)
786
        .account_id(account_id)
787
        .run()
788
        .await?;
789

            
790
    let commodities = if let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result {
791
        commodities
792
    } else {
793
        return Ok((
794
            Rational64::new(0, 1),
795
            "No transaction yet".to_string(),
796
            "NONE".to_string(),
797
        ));
798
    };
799

            
800
    // Get balance without specifying commodity to get multi-currency result
801
    let balance_result = GetBalance::new()
802
        .user_id(user_id)
803
        .account_id(account_id)
804
        .run()
805
        .await?;
806

            
807
    match balance_result {
808
        Some(CmdResult::MultiCurrencyBalance(balances)) => {
809
            if balances.is_empty() {
810
                // No transactions yet
811
                Ok((
812
                    Rational64::new(0, 1),
813
                    "No transaction yet".to_string(),
814
                    "NONE".to_string(),
815
                ))
816
            } else if balances.len() == 1 {
817
                // Single currency - need to get commodity info
818
                let (commodity, balance) = &balances[0];
819
                let commodity_info = commodities
820
                    .iter()
821
                    .find(|c| c.commodity_id == commodity.id)
822
                    .map(|c| (c.name.clone(), c.symbol.clone()))
823
                    .unwrap_or_else(|| ("Unknown".to_string(), "?".to_string()));
824
                Ok((*balance, commodity_info.0, commodity_info.1))
825
            } else {
826
                // Multiple currencies - show all balances comma-separated
827
                let balance_strings: Vec<String> = balances
828
                    .iter()
829
                    .map(|(commodity, balance)| {
830
                        let commodity_info = commodities
831
                            .iter()
832
                            .find(|c| c.commodity_id == commodity.id)
833
                            .map(|c| (c.name.clone(), c.symbol.clone()))
834
                            .unwrap_or_else(|| ("Unknown".to_string(), "?".to_string()));
835
                        format!("{} {}", balance, commodity_info.1)
836
                    })
837
                    .collect();
838

            
839
                // Use first balance for the numeric part (for compatibility)
840
                let (first_commodity, first_balance) = &balances[0];
841
                let first_commodity_info = commodities
842
                    .iter()
843
                    .find(|c| c.commodity_id == first_commodity.id)
844
                    .map(|c| (c.name.clone(), c.symbol.clone()))
845
                    .unwrap_or_else(|| ("Unknown".to_string(), "?".to_string()));
846

            
847
                Ok((
848
                    *first_balance,
849
                    balance_strings.join(", "),
850
                    first_commodity_info.1,
851
                ))
852
            }
853
        }
854
        Some(CmdResult::Rational(balance)) => {
855
            // Single currency result (when commodity_id was specified)
856
            if balance == Rational64::new(0, 1) {
857
                // Zero balance - show "No transaction yet" message
858
                Ok((
859
                    Rational64::new(0, 1),
860
                    "No transaction yet".to_string(),
861
                    "NONE".to_string(),
862
                ))
863
            } else if commodities.is_empty() {
864
                Ok((balance, "Unknown".to_string(), "?".to_string()))
865
            } else {
866
                let commodity = &commodities[0];
867
                Ok((balance, commodity.name.clone(), commodity.symbol.clone()))
868
            }
869
        }
870
        None => {
871
            // Zero balance
872
            Ok((
873
                Rational64::new(0, 1),
874
                "No transaction yet".to_string(),
875
                "NONE".to_string(),
876
            ))
877
        }
878
        _ => Err(CmdError::Args(
879
            "Unexpected result type from GetBalance".to_string(),
880
        )),
881
    }
882
}
883

            
884
#[derive(Debug)]
885
pub struct CliAccountBalance;
886

            
887
impl CliRunnable for CliAccountBalance {
888
    fn run<'a>(
889
        &'a self,
890
        args: &'a HashMap<&str, &Argument>,
891
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
892
        Box::pin(async move {
893
            // Extract account ID from arguments
894
            let account_id = if let Some(Argument::Uuid(account_id)) = args.get("account") {
895
                *account_id
896
            } else {
897
                return Err(CommandError::Argument("Account ID is required".to_string()));
898
            };
899

            
900
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
901
                *user_id
902
            } else {
903
                return Err(CommandError::Argument("User ID is required".to_string()));
904
            };
905

            
906
            // Get balance with currency information
907
            let (balance, currency_name, currency_symbol) =
908
                get_cli_balance_with_currency(account_id, user_id)
909
                    .await
910
                    .map_err(|e| {
911
                        CommandError::Argument(format!("Balance calculation failed: {}", e))
912
                    })?;
913

            
914
            // Format the result to include currency information
915
            let formatted_result = if currency_name.contains(", ") {
916
                // Multi-currency: show only the comma-separated list
917
                currency_name
918
            } else {
919
                // Single currency: show traditional format
920
                format!("{} {} ({})", balance, currency_symbol, currency_name)
921
            };
922

            
923
            Ok(Some(CmdResult::String(formatted_result)))
924
        })
925
    }
926
}
927

            
928
impl CliCommand for CliAccountBalance {
929
    fn node() -> CommandNode {
930
        CommandNode {
931
            name: "balance".to_string(),
932
            command: Some(Box::new(CliAccountBalance)),
933
            comment: "Get the current balance and currency of an account".to_string(),
934
            subcommands: vec![],
935
            arguments: vec![ArgumentNode {
936
                name: "account".to_string(),
937
                comment: "Account ID to get balance for".to_string(),
938
                completions: Some(Box::new(CliAccountCompletion)),
939
            }],
940
        }
941
    }
942
}