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
                (
568
                    Some(CmdResult::TaggedEntities {
569
                        entities: from_entities,
570
                        ..
571
                    }),
572
                    Some(CmdResult::TaggedEntities {
573
                        entities: to_entities,
574
                        ..
575
                    }),
576
                ) => {
577
                    let from_fraction =
578
                        if let Some((FinanceEntity::Commodity(c), _)) = from_entities.first() {
579
                            c.fraction
580
                        } else {
581
                            return Err(CommandError::Execution(CmdError::Args(
582
                                "From commodity not found".to_string(),
583
                            )));
584
                        };
585

            
586
                    let to_fraction =
587
                        if let Some((FinanceEntity::Commodity(c), _)) = to_entities.first() {
588
                            c.fraction
589
                        } else {
590
                            return Err(CommandError::Execution(CmdError::Args(
591
                                "To commodity not found".to_string(),
592
                            )));
593
                        };
594

            
595
                    (from_fraction, to_fraction)
596
                }
597
                _ => {
598
                    return Err(CommandError::Execution(CmdError::Args(
599
                        "Could not retrieve commodity information".to_string(),
600
                    )));
601
                }
602
            };
603

            
604
            // Create splits
605
            let split1 = Split {
606
                id: from_split_id,
607
                tx_id,
608
                account_id: *from_account,
609
                commodity_id: from_currency,
610
                value_num: -*value.numer(),
611
                value_denom: *value.denom(),
612
                reconcile_state: None,
613
                reconcile_date: None,
614
                lot_id: None,
615
            };
616

            
617
            let split2 = Split {
618
                id: to_split_id,
619
                tx_id,
620
                account_id: *to_account,
621
                commodity_id: to_currency,
622
                value_num: *to_amount.numer(),
623
                value_denom: *to_amount.denom(),
624
                reconcile_state: None,
625
                reconcile_date: None,
626
                lot_id: None,
627
            };
628

            
629
            let note = if let Some(Argument::String(note)) = args.get("note") {
630
                Some(note)
631
            } else {
632
                None
633
            };
634

            
635
            let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
636
            let mut prices = vec![];
637

            
638
            // Create price conversion if currencies differ
639
            if currencies_differ {
640
                let price = Price {
641
                    id: Uuid::new_v4(),
642
                    date: now,
643
                    commodity_id: to_currency,
644
                    currency_id: from_currency,
645
                    commodity_split: Some(to_split_id),
646
                    currency_split: Some(from_split_id),
647
                    value_num: *value.numer(),
648
                    value_denom: *to_amount.numer(),
649
                };
650
                prices.push(FinanceEntity::Price(price));
651
            }
652

            
653
            let mut cmd = CreateTransaction::new()
654
                .user_id(user_id)
655
                .splits(splits)
656
                .id(tx_id)
657
                .post_date(now)
658
                .enter_date(now);
659

            
660
            if !prices.is_empty() {
661
                cmd = cmd.prices(prices);
662
            }
663

            
664
            if let Some(note) = note {
665
                cmd = cmd.note(note.clone());
666
            }
667

            
668
            Ok(cmd.run().await?)
669
        })
670
    }
671
}
672

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

            
722
#[derive(Debug)]
723
pub struct CliTransactionList;
724

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

            
739
            let mut cmd = ListTransactions::new().user_id(user_id);
740

            
741
            // Add optional account filter if provided
742
            if let Some(Argument::Uuid(account_id)) = args.get("account") {
743
                cmd = cmd.account(*account_id);
744
            }
745

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

            
770
impl CliCommand for CliTransactionList {
771
    fn node() -> CommandNode {
772
        CommandNode {
773
            name: "list".to_string(),
774
            command: Some(Box::new(CliTransactionList)),
775
            comment: "List all transactions".to_string(),
776
            subcommands: vec![],
777
            arguments: vec![ArgumentNode {
778
                name: "account".to_string(),
779
                comment: "Optional account to filter by".to_string(),
780
                completions: Some(Box::new(CliAccountCompletion)),
781
            }],
782
        }
783
    }
784
}
785

            
786
async fn get_cli_balance_with_currency(
787
    account_id: Uuid,
788
    user_id: Uuid,
789
) -> Result<(Rational64, String, String), CmdError> {
790
    // First get the commodity information for this account
791
    let commodities_result = GetAccountCommodities::new()
792
        .user_id(user_id)
793
        .account_id(account_id)
794
        .run()
795
        .await?;
796

            
797
    let commodities = if let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result {
798
        commodities
799
    } else {
800
        return Ok((
801
            Rational64::new(0, 1),
802
            "No transaction yet".to_string(),
803
            "NONE".to_string(),
804
        ));
805
    };
806

            
807
    // Get balance without specifying commodity to get multi-currency result
808
    let balance_result = GetBalance::new()
809
        .user_id(user_id)
810
        .account_id(account_id)
811
        .run()
812
        .await?;
813

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

            
850
                // Use first balance for the numeric part (for compatibility)
851
                let (first_commodity, first_balance) = &balances[0];
852
                let first_commodity_info = commodities
853
                    .iter()
854
                    .find(|c| c.commodity_id == first_commodity.id)
855
                    .map_or_else(
856
                        || ("Unknown".to_string(), "?".to_string()),
857
                        |c| (c.name.clone(), c.symbol.clone()),
858
                    );
859

            
860
                Ok((
861
                    *first_balance,
862
                    balance_strings.join(", "),
863
                    first_commodity_info.1,
864
                ))
865
            }
866
        }
867
        Some(CmdResult::Rational(balance)) => {
868
            // Single currency result (when commodity_id was specified)
869
            if balance == Rational64::new(0, 1) {
870
                // Zero balance - show "No transaction yet" message
871
                Ok((
872
                    Rational64::new(0, 1),
873
                    "No transaction yet".to_string(),
874
                    "NONE".to_string(),
875
                ))
876
            } else if commodities.is_empty() {
877
                Ok((balance, "Unknown".to_string(), "?".to_string()))
878
            } else {
879
                let commodity = &commodities[0];
880
                Ok((balance, commodity.name.clone(), commodity.symbol.clone()))
881
            }
882
        }
883
        None => {
884
            // Zero balance
885
            Ok((
886
                Rational64::new(0, 1),
887
                "No transaction yet".to_string(),
888
                "NONE".to_string(),
889
            ))
890
        }
891
        _ => Err(CmdError::Args(
892
            "Unexpected result type from GetBalance".to_string(),
893
        )),
894
    }
895
}
896

            
897
#[derive(Debug)]
898
pub struct CliAccountBalance;
899

            
900
impl CliRunnable for CliAccountBalance {
901
    fn run<'a>(
902
        &'a self,
903
        args: &'a HashMap<&str, &Argument>,
904
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
905
        Box::pin(async move {
906
            // Extract account ID from arguments
907
            let account_id = if let Some(Argument::Uuid(account_id)) = args.get("account") {
908
                *account_id
909
            } else {
910
                return Err(CommandError::Argument("Account ID is required".to_string()));
911
            };
912

            
913
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
914
                *user_id
915
            } else {
916
                return Err(CommandError::Argument("User ID is required".to_string()));
917
            };
918

            
919
            // Get balance with currency information
920
            let (balance, currency_name, currency_symbol) =
921
                get_cli_balance_with_currency(account_id, user_id)
922
                    .await
923
                    .map_err(|e| {
924
                        CommandError::Argument(format!("Balance calculation failed: {e}"))
925
                    })?;
926

            
927
            // Format the result to include currency information
928
            let formatted_result = if currency_name.contains(", ") {
929
                // Multi-currency: show only the comma-separated list
930
                currency_name
931
            } else {
932
                // Single currency: show traditional format
933
                format!("{balance} {currency_symbol} ({currency_name})")
934
            };
935

            
936
            Ok(Some(CmdResult::String(formatted_result)))
937
        })
938
    }
939
}
940

            
941
impl CliCommand for CliAccountBalance {
942
    fn node() -> CommandNode {
943
        CommandNode {
944
            name: "balance".to_string(),
945
            command: Some(Box::new(CliAccountBalance)),
946
            comment: "Get the current balance and currency of an account".to_string(),
947
            subcommands: vec![],
948
            arguments: vec![ArgumentNode {
949
                name: "account".to_string(),
950
                comment: "Account ID to get balance for".to_string(),
951
                completions: Some(Box::new(CliAccountCompletion)),
952
            }],
953
        }
954
    }
955
}