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::ListCommodities, config::GetConfig, config::GetVersion,
8
    config::SelectColumn, config::SetConfig, transaction::CreateTransaction,
9
    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 (args.get("symbol"), args.get("name"), args.get("user_id")) {
230
                (
231
                    Some(Argument::String(symbol)),
232
                    Some(Argument::String(name)),
233
                    Some(Argument::Uuid(user_id)),
234
                ) => Ok(CreateCommodity::new()
235
                    .symbol(symbol.clone())
236
                    .name(name.clone())
237
                    .user_id(*user_id)
238
                    .run()
239
                    .await?),
240
                _ => Err(CommandError::Argument(
241
                    "Provide symbol, name, user_id".to_string(),
242
                )),
243
            }
244
        })
245
    }
246
}
247

            
248
impl CliCommand for CliCommodityCreate {
249
    fn node() -> CommandNode {
250
        CommandNode {
251
            name: "create".to_string(),
252
            command: Some(Box::new(CliCommodityCreate)),
253
            comment: "Create new commodity".to_string(),
254
            subcommands: vec![],
255
            arguments: vec![
256
                ArgumentNode {
257
                    name: "symbol".to_string(),
258
                    comment: "The abbreviation (or symbol) of the commodity".to_string(),
259
                    completions: None,
260
                },
261
                ArgumentNode {
262
                    name: "name".to_string(),
263
                    comment: "Human-readable name of commodity".to_string(),
264
                    completions: None,
265
                },
266
            ],
267
        }
268
    }
269
}
270

            
271
#[derive(Debug)]
272
pub struct CliCommodityList;
273

            
274
impl CliRunnable for CliCommodityList {
275
    fn run<'a>(
276
        &'a self,
277
        args: &'a HashMap<&str, &Argument>,
278
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
279
        Box::pin(async move {
280
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
281
                *user_id
282
            } else {
283
                return Err(CommandError::Execution(CmdError::Args(
284
                    "user_id is required".to_string(),
285
                )));
286
            };
287

            
288
            let result = ListCommodities::new().user_id(user_id).run().await?;
289
            if let Some(CmdResult::TaggedEntities { entities, .. }) = result {
290
                let mut result: Vec<String> = vec![];
291
                for (_, tags) in entities {
292
                    if let (FinanceEntity::Tag(s), FinanceEntity::Tag(n)) =
293
                        (&tags["symbol"], &tags["name"])
294
                    {
295
                        result.push(format!("{} - {}", s.tag_value, n.tag_value));
296
                    }
297
                }
298
                Ok(Some(CmdResult::Lines(result)))
299
            } else {
300
                Ok(None)
301
            }
302
        })
303
    }
304
}
305

            
306
impl CliCommand for CliCommodityList {
307
    fn node() -> CommandNode {
308
        CommandNode {
309
            name: "list".to_string(),
310
            command: Some(Box::new(CliCommodityList)),
311
            comment: "List all commodities".to_string(),
312
            subcommands: vec![],
313
            arguments: vec![],
314
        }
315
    }
316
}
317

            
318
#[derive(Debug)]
319
pub struct CliCommodityCompletion;
320

            
321
impl CliRunnable for CliCommodityCompletion {
322
    fn run<'a>(
323
        &'a self,
324
        args: &'a HashMap<&str, &Argument>,
325
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
326
        Box::pin(async move {
327
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
328
                *user_id
329
            } else {
330
                return Err(CommandError::Execution(CmdError::Args(
331
                    "user_id is required".to_string(),
332
                )));
333
            };
334

            
335
            Ok(ListCommodities::new().user_id(user_id).run().await?)
336
        })
337
    }
338
}
339

            
340
#[derive(Debug)]
341
pub struct CliAccountCreate;
342

            
343
impl CliRunnable for CliAccountCreate {
344
    fn run<'a>(
345
        &'a self,
346
        args: &'a HashMap<&str, &Argument>,
347
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
348
        Box::pin(async move {
349
            let name = if let Some(Argument::String(name)) = args.get("name") {
350
                name.clone()
351
            } else {
352
                return Err(CommandError::Execution(CmdError::Args(
353
                    "name is required".to_string(),
354
                )));
355
            };
356

            
357
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
358
                *user_id
359
            } else {
360
                return Err(CommandError::Execution(CmdError::Args(
361
                    "user_id is required".to_string(),
362
                )));
363
            };
364

            
365
            let mut builder = CreateAccount::new().name(name).user_id(user_id);
366

            
367
            // Handle optional parent parameter
368
            if let Some(Argument::Uuid(parent_id)) = args.get("parent") {
369
                builder = builder.parent(*parent_id);
370
            }
371

            
372
            Ok(builder.run().await?)
373
        })
374
    }
375
}
376

            
377
impl CliCommand for CliAccountCreate {
378
    fn node() -> CommandNode {
379
        CommandNode {
380
            name: "create".to_string(),
381
            command: Some(Box::new(CliAccountCreate)),
382
            comment: "Create new account".to_string(),
383
            subcommands: vec![],
384
            arguments: vec![
385
                ArgumentNode {
386
                    name: "name".to_string(),
387
                    comment: "Name of the account".to_string(),
388
                    completions: None,
389
                },
390
                ArgumentNode {
391
                    name: "parent".to_string(),
392
                    comment: "Optional parent account".to_string(),
393
                    completions: None,
394
                },
395
            ],
396
        }
397
    }
398
}
399

            
400
#[derive(Debug)]
401
pub struct CliAccountList;
402

            
403
impl CliRunnable for CliAccountList {
404
    fn run<'a>(
405
        &'a self,
406
        args: &'a HashMap<&str, &Argument>,
407
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
408
        Box::pin(async move {
409
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
410
                *user_id
411
            } else {
412
                return Err(CommandError::Execution(CmdError::Args(
413
                    "user_id is required".to_string(),
414
                )));
415
            };
416

            
417
            let result = ListAccounts::new().user_id(user_id).run().await?;
418
            if let Some(CmdResult::TaggedEntities { entities, .. }) = result {
419
                let mut result: Vec<String> = vec![];
420
                for (_, tags) in entities {
421
                    if let FinanceEntity::Tag(n) = &tags["name"] {
422
                        result.push(n.tag_value.clone());
423
                    }
424
                }
425
                Ok(Some(CmdResult::Lines(result)))
426
            } else {
427
                Ok(None)
428
            }
429
        })
430
    }
431
}
432

            
433
impl CliCommand for CliAccountList {
434
    fn node() -> CommandNode {
435
        CommandNode {
436
            name: "list".to_string(),
437
            command: Some(Box::new(CliAccountList)),
438
            comment: "List all accounts".to_string(),
439
            subcommands: vec![],
440
            arguments: vec![],
441
        }
442
    }
443
}
444

            
445
#[derive(Debug)]
446
pub struct CliAccountCompletion;
447

            
448
impl CliRunnable for CliAccountCompletion {
449
    fn run<'a>(
450
        &'a self,
451
        args: &'a HashMap<&str, &Argument>,
452
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
453
        Box::pin(async move {
454
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
455
                *user_id
456
            } else {
457
                return Err(CommandError::Execution(CmdError::Args(
458
                    "user_id is required".to_string(),
459
                )));
460
            };
461

            
462
            Ok(ListAccounts::new().user_id(user_id).run().await?)
463
        })
464
    }
465
}
466

            
467
#[derive(Debug)]
468
pub struct CliTransactionCreate;
469

            
470
impl CliRunnable for CliTransactionCreate {
471
    fn run<'a>(
472
        &'a self,
473
        args: &'a HashMap<&str, &Argument>,
474
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
475
        Box::pin(async move {
476
            let from_account = if let Some(Argument::Uuid(from)) = args.get("from") {
477
                from
478
            } else {
479
                return Err(CommandError::Argument(
480
                    "from account not provided".to_string(),
481
                ));
482
            };
483

            
484
            let to_account = if let Some(Argument::Uuid(to)) = args.get("to") {
485
                to
486
            } else {
487
                return Err(CommandError::Argument(
488
                    "to account not provided".to_string(),
489
                ));
490
            };
491

            
492
            let value = if let Some(Argument::Rational(val)) = args.get("value") {
493
                val
494
            } else {
495
                return Err(CommandError::Argument("value not provided".to_string()));
496
            };
497

            
498
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
499
                *user_id
500
            } else {
501
                return Err(CommandError::Argument("User ID is required".to_string()));
502
            };
503

            
504
            let from_currency =
505
                if let Some(Argument::Uuid(from_currency)) = args.get("from_currency") {
506
                    *from_currency
507
                } else {
508
                    return Err(CommandError::Argument(
509
                        "from_currency is required".to_string(),
510
                    ));
511
                };
512

            
513
            let to_currency = if let Some(Argument::Uuid(to_currency)) = args.get("to_currency") {
514
                *to_currency
515
            } else {
516
                return Err(CommandError::Argument(
517
                    "to_currency is required".to_string(),
518
                ));
519
            };
520

            
521
            let tx_id = Uuid::new_v4();
522
            let now = Utc::now();
523
            let from_split_id = Uuid::new_v4();
524
            let to_split_id = Uuid::new_v4();
525

            
526
            // Check if currencies differ - if so, we need to_amount for conversion
527
            let currencies_differ = from_currency != to_currency;
528
            let to_amount = if currencies_differ {
529
                if let Some(Argument::Rational(to_val)) = args.get("to_amount") {
530
                    *to_val
531
                } else {
532
                    return Err(CommandError::Argument(
533
                        "to_amount is required when currencies differ".to_string(),
534
                    ));
535
                }
536
            } else {
537
                *value
538
            };
539

            
540
            // Create splits
541
            let split1 = Split {
542
                id: from_split_id,
543
                tx_id,
544
                account_id: *from_account,
545
                commodity_id: from_currency,
546
                value_num: -*value.numer(),
547
                value_denom: *value.denom(),
548
                reconcile_state: None,
549
                reconcile_date: None,
550
                lot_id: None,
551
            };
552

            
553
            let split2 = Split {
554
                id: to_split_id,
555
                tx_id,
556
                account_id: *to_account,
557
                commodity_id: to_currency,
558
                value_num: *to_amount.numer(),
559
                value_denom: *to_amount.denom(),
560
                reconcile_state: None,
561
                reconcile_date: None,
562
                lot_id: None,
563
            };
564

            
565
            let note = if let Some(Argument::String(note)) = args.get("note") {
566
                Some(note)
567
            } else {
568
                None
569
            };
570

            
571
            let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
572
            let mut prices = vec![];
573

            
574
            // Create price conversion if currencies differ
575
            if currencies_differ {
576
                let price = Price {
577
                    id: Uuid::new_v4(),
578
                    date: now,
579
                    commodity_id: to_currency,
580
                    currency_id: from_currency,
581
                    commodity_split: Some(to_split_id),
582
                    currency_split: Some(from_split_id),
583
                    value_num: *value.numer(),
584
                    value_denom: *to_amount.numer(),
585
                };
586
                prices.push(FinanceEntity::Price(price));
587
            }
588

            
589
            let mut cmd = CreateTransaction::new()
590
                .user_id(user_id)
591
                .splits(splits)
592
                .id(tx_id)
593
                .post_date(now)
594
                .enter_date(now);
595

            
596
            if !prices.is_empty() {
597
                cmd = cmd.prices(prices);
598
            }
599

            
600
            if let Some(note) = note {
601
                cmd = cmd.note(note.clone());
602
            }
603

            
604
            Ok(cmd.run().await?)
605
        })
606
    }
607
}
608

            
609
impl CliCommand for CliTransactionCreate {
610
    fn node() -> CommandNode {
611
        CommandNode {
612
            name: "create".to_string(),
613
            command: Some(Box::new(CliTransactionCreate)),
614
            comment: "Create new transaction".to_string(),
615
            subcommands: vec![],
616
            arguments: vec![
617
                ArgumentNode {
618
                    name: "from".to_string(),
619
                    comment: "Source account".to_string(),
620
                    completions: Some(Box::new(CliAccountCompletion)),
621
                },
622
                ArgumentNode {
623
                    name: "to".to_string(),
624
                    comment: "Destination account".to_string(),
625
                    completions: Some(Box::new(CliAccountCompletion)),
626
                },
627
                ArgumentNode {
628
                    name: "from_currency".to_string(),
629
                    comment: "Currency for the source transaction".to_string(),
630
                    completions: Some(Box::new(CliCommodityCompletion)),
631
                },
632
                ArgumentNode {
633
                    name: "to_currency".to_string(),
634
                    comment: "Currency for the destination transaction".to_string(),
635
                    completions: Some(Box::new(CliCommodityCompletion)),
636
                },
637
                ArgumentNode {
638
                    name: "value".to_string(),
639
                    comment: "Transaction amount (from account)".to_string(),
640
                    completions: None,
641
                },
642
                ArgumentNode {
643
                    name: "to_amount".to_string(),
644
                    comment: "Transaction amount (to account, required when currencies differ)"
645
                        .to_string(),
646
                    completions: None,
647
                },
648
                ArgumentNode {
649
                    name: "note".to_string(),
650
                    comment: "Text memo for transaction".to_string(),
651
                    completions: None,
652
                },
653
            ],
654
        }
655
    }
656
}
657

            
658
#[derive(Debug)]
659
pub struct CliTransactionList;
660

            
661
impl CliRunnable for CliTransactionList {
662
    fn run<'a>(
663
        &'a self,
664
        args: &'a HashMap<&str, &Argument>,
665
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
666
        Box::pin(async move {
667
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
668
                *user_id
669
            } else {
670
                return Err(CommandError::Execution(CmdError::Args(
671
                    "user_id is required".to_string(),
672
                )));
673
            };
674

            
675
            let mut cmd = ListTransactions::new().user_id(user_id);
676

            
677
            // Add optional account filter if provided
678
            if let Some(Argument::Uuid(account_id)) = args.get("account") {
679
                cmd = cmd.account(*account_id);
680
            }
681

            
682
            let result = cmd.run().await?;
683
            if let Some(CmdResult::TaggedEntities { entities, .. }) = result {
684
                let mut result: Vec<String> = vec![];
685
                for (entity, tags) in entities {
686
                    if let FinanceEntity::Transaction(tx) = entity {
687
                        result.push(format!(
688
                            "{} - {}",
689
                            if let Some(FinanceEntity::Tag(note)) = tags.get("note") {
690
                                note.tag_value.clone()
691
                            } else {
692
                                tx.id.to_string()
693
                            },
694
                            tx.post_date
695
                        ));
696
                    }
697
                }
698
                Ok(Some(CmdResult::Lines(result)))
699
            } else {
700
                Ok(None)
701
            }
702
        })
703
    }
704
}
705

            
706
impl CliCommand for CliTransactionList {
707
    fn node() -> CommandNode {
708
        CommandNode {
709
            name: "list".to_string(),
710
            command: Some(Box::new(CliTransactionList)),
711
            comment: "List all transactions".to_string(),
712
            subcommands: vec![],
713
            arguments: vec![ArgumentNode {
714
                name: "account".to_string(),
715
                comment: "Optional account to filter by".to_string(),
716
                completions: Some(Box::new(CliAccountCompletion)),
717
            }],
718
        }
719
    }
720
}
721

            
722
async fn get_cli_balance_with_currency(
723
    account_id: Uuid,
724
    user_id: Uuid,
725
) -> Result<(Rational64, String, String), CmdError> {
726
    // First get the commodity information for this account
727
    let commodities_result = GetAccountCommodities::new()
728
        .user_id(user_id)
729
        .account_id(account_id)
730
        .run()
731
        .await?;
732

            
733
    let commodities = if let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result {
734
        commodities
735
    } else {
736
        return Ok((
737
            Rational64::new(0, 1),
738
            "No transaction yet".to_string(),
739
            "NONE".to_string(),
740
        ));
741
    };
742

            
743
    // Get balance without specifying commodity to get multi-currency result
744
    let balance_result = GetBalance::new()
745
        .user_id(user_id)
746
        .account_id(account_id)
747
        .run()
748
        .await?;
749

            
750
    match balance_result {
751
        Some(CmdResult::MultiCurrencyBalance(balances)) => {
752
            if balances.is_empty() {
753
                // No transactions yet
754
                Ok((
755
                    Rational64::new(0, 1),
756
                    "No transaction yet".to_string(),
757
                    "NONE".to_string(),
758
                ))
759
            } else if balances.len() == 1 {
760
                // Single currency - need to get commodity info
761
                let (commodity, balance) = &balances[0];
762
                let commodity_info = commodities
763
                    .iter()
764
                    .find(|c| c.commodity_id == commodity.id)
765
                    .map_or_else(
766
                        || ("Unknown".to_string(), "?".to_string()),
767
                        |c| (c.name.clone(), c.symbol.clone()),
768
                    );
769
                Ok((*balance, commodity_info.0, commodity_info.1))
770
            } else {
771
                // Multiple currencies - show all balances comma-separated
772
                let balance_strings: Vec<String> = balances
773
                    .iter()
774
                    .map(|(commodity, balance)| {
775
                        let commodity_info = commodities
776
                            .iter()
777
                            .find(|c| c.commodity_id == commodity.id)
778
                            .map_or_else(
779
                                || ("Unknown".to_string(), "?".to_string()),
780
                                |c| (c.name.clone(), c.symbol.clone()),
781
                            );
782
                        format!("{} {}", balance, commodity_info.1)
783
                    })
784
                    .collect();
785

            
786
                // Use first balance for the numeric part (for compatibility)
787
                let (first_commodity, first_balance) = &balances[0];
788
                let first_commodity_info = commodities
789
                    .iter()
790
                    .find(|c| c.commodity_id == first_commodity.id)
791
                    .map_or_else(
792
                        || ("Unknown".to_string(), "?".to_string()),
793
                        |c| (c.name.clone(), c.symbol.clone()),
794
                    );
795

            
796
                Ok((
797
                    *first_balance,
798
                    balance_strings.join(", "),
799
                    first_commodity_info.1,
800
                ))
801
            }
802
        }
803
        Some(CmdResult::Rational(balance)) => {
804
            // Single currency result (when commodity_id was specified)
805
            if balance == Rational64::new(0, 1) {
806
                // Zero balance - show "No transaction yet" message
807
                Ok((
808
                    Rational64::new(0, 1),
809
                    "No transaction yet".to_string(),
810
                    "NONE".to_string(),
811
                ))
812
            } else if commodities.is_empty() {
813
                Ok((balance, "Unknown".to_string(), "?".to_string()))
814
            } else {
815
                let commodity = &commodities[0];
816
                Ok((balance, commodity.name.clone(), commodity.symbol.clone()))
817
            }
818
        }
819
        None => {
820
            // Zero balance
821
            Ok((
822
                Rational64::new(0, 1),
823
                "No transaction yet".to_string(),
824
                "NONE".to_string(),
825
            ))
826
        }
827
        _ => Err(CmdError::Args(
828
            "Unexpected result type from GetBalance".to_string(),
829
        )),
830
    }
831
}
832

            
833
#[derive(Debug)]
834
pub struct CliAccountBalance;
835

            
836
impl CliRunnable for CliAccountBalance {
837
    fn run<'a>(
838
        &'a self,
839
        args: &'a HashMap<&str, &Argument>,
840
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
841
        Box::pin(async move {
842
            // Extract account ID from arguments
843
            let account_id = if let Some(Argument::Uuid(account_id)) = args.get("account") {
844
                *account_id
845
            } else {
846
                return Err(CommandError::Argument("Account ID is required".to_string()));
847
            };
848

            
849
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
850
                *user_id
851
            } else {
852
                return Err(CommandError::Argument("User ID is required".to_string()));
853
            };
854

            
855
            // Get balance with currency information
856
            let (balance, currency_name, currency_symbol) =
857
                get_cli_balance_with_currency(account_id, user_id)
858
                    .await
859
                    .map_err(|e| {
860
                        CommandError::Argument(format!("Balance calculation failed: {e}"))
861
                    })?;
862

            
863
            // Format the result to include currency information
864
            let formatted_result = if currency_name.contains(", ") {
865
                // Multi-currency: show only the comma-separated list
866
                currency_name
867
            } else {
868
                // Single currency: show traditional format
869
                format!("{balance} {currency_symbol} ({currency_name})")
870
            };
871

            
872
            Ok(Some(CmdResult::String(formatted_result)))
873
        })
874
    }
875
}
876

            
877
impl CliCommand for CliAccountBalance {
878
    fn node() -> CommandNode {
879
        CommandNode {
880
            name: "balance".to_string(),
881
            command: Some(Box::new(CliAccountBalance)),
882
            comment: "Get the current balance and currency of an account".to_string(),
883
            subcommands: vec![],
884
            arguments: vec![ArgumentNode {
885
                name: "account".to_string(),
886
                comment: "Account ID to get balance for".to_string(),
887
                completions: Some(Box::new(CliAccountCompletion)),
888
            }],
889
        }
890
    }
891
}