1
use finance::{account::Account, commodity::Commodity, error::FinanceError, tag::Tag};
2
use num_rational::Rational64;
3
use sqlx::types::Uuid;
4
use std::{collections::HashMap, fmt::Debug};
5
use supp_macro::command;
6

            
7
use crate::{command::CommodityInfo, config::ConfigError, user::User};
8
use finance::error::BalanceError;
9

            
10
use super::{CmdError, CmdResult, FinanceEntity};
11

            
12
command! {
13
    CreateAccount {
14
        #[required]
15
        name: String,
16
        #[required]
17
        user_id: Uuid,
18
        #[optional]
19
        parent: Uuid,
20
    } => {
21
        let user = User { id: user_id };
22

            
23
        Ok(Some(CmdResult::Entity(FinanceEntity::Account(
24
            user.create_account(
25
                &name,
26
                parent,
27
            )
28
            .await?,
29
        ))))
30
    }
31
12680
}
32

            
33
command! {
34
    ListAccounts {
35
        #[required]
36
        user_id: Uuid,
37
    } => {
38
        let user = User { id: user_id };
39
300
        let mut conn = user.get_connection().await.map_err(|err| {
40
300
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
41
300
            ConfigError::DB
42
300
        })?;
43

            
44
        // Get all accounts with their commodities
45
        let mut tagged_accounts = Vec::new();
46
        let rows = sqlx::query_file!("sql/select/accounts/all.sql")
47
            .fetch_all(&mut *conn)
48
            .await?;
49

            
50
        for row in rows {
51
            let account = Account {
52
                id: row.id,
53
                parent: row.parent_id,
54
            };
55

            
56
            // Get tags for this account
57
            let tags: HashMap<String, FinanceEntity> =
58
                sqlx::query_file!("sql/select/tags/by_account.sql", &account.id)
59
                    .fetch_all(&mut *conn)
60
                    .await?
61
                    .into_iter()
62
770
                    .map(|row| {
63
770
                        (
64
770
                            row.tag_name.clone(),
65
770
                            FinanceEntity::Tag(Tag {
66
770
                                id: row.id,
67
770
                                tag_name: row.tag_name,
68
770
                                tag_value: row.tag_value,
69
770
                                description: row.description,
70
770
                            }),
71
770
                        )
72
770
                    })
73
                    .collect();
74

            
75
            // Now push them, no need to borrow from the vector.
76
            tagged_accounts.push((FinanceEntity::Account(account), tags));
77
        }
78

            
79
        Ok(Some(CmdResult::TaggedEntities {
80
            entities: tagged_accounts,
81
            pagination: None,
82
        }))
83
    }
84
4192
}
85

            
86
command! {
87
    ListAccountsForManage {
88
        #[required]
89
        user_id: Uuid,
90
    } => {
91
        let user = User { id: user_id };
92
25
        let mut conn = user.get_connection().await.map_err(|err| {
93
25
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
94
25
            ConfigError::DB
95
25
        })?;
96

            
97
        let rows = sqlx::query_file!("sql/select/accounts/manage_tree.sql")
98
            .fetch_all(&mut *conn)
99
            .await?;
100

            
101
        let mut tagged_accounts = Vec::new();
102
        for row in rows {
103
            let account = Account {
104
                id: row.id,
105
                parent: row.parent_id,
106
            };
107

            
108
            let tags: HashMap<String, FinanceEntity> =
109
                sqlx::query_file!("sql/select/tags/by_account.sql", &account.id)
110
                    .fetch_all(&mut *conn)
111
                    .await?
112
                    .into_iter()
113
                    .map(|tag_row| {
114
                        (
115
                            tag_row.tag_name.clone(),
116
                            FinanceEntity::Tag(Tag {
117
                                id: tag_row.id,
118
                                tag_name: tag_row.tag_name,
119
                                tag_value: tag_row.tag_value,
120
                                description: tag_row.description,
121
                            }),
122
                        )
123
                    })
124
                    .collect();
125

            
126
            tagged_accounts.push((FinanceEntity::Account(account), tags));
127
        }
128

            
129
        Ok(Some(CmdResult::TaggedEntities {
130
            entities: tagged_accounts,
131
            pagination: None,
132
        }))
133
    }
134
170
}
135

            
136
command! {
137
    GetAccountForManage {
138
        #[required]
139
        user_id: Uuid,
140
        #[required]
141
        account_id: Uuid,
142
    } => {
143
        let user = User { id: user_id };
144
        let mut conn = user.get_connection().await.map_err(|err| {
145
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
146
            ConfigError::DB
147
        })?;
148

            
149
        let row = sqlx::query_file!("sql/select/accounts/manage_details.sql", &account_id)
150
            .fetch_optional(&mut *conn)
151
            .await?;
152

            
153
        let Some(row) = row else {
154
            return Ok(Some(CmdResult::TaggedEntities {
155
                entities: vec![],
156
                pagination: None,
157
            }));
158
        };
159

            
160
        let account = Account {
161
            id: row.id,
162
            parent: row.parent_id,
163
        };
164

            
165
        let tags: HashMap<String, FinanceEntity> =
166
            sqlx::query_file!("sql/select/tags/by_account.sql", &account.id)
167
                .fetch_all(&mut *conn)
168
                .await?
169
                .into_iter()
170
25
                .map(|tag_row| {
171
25
                    (
172
25
                        tag_row.tag_name.clone(),
173
25
                        FinanceEntity::Tag(Tag {
174
25
                            id: tag_row.id,
175
25
                            tag_name: tag_row.tag_name,
176
25
                            tag_value: tag_row.tag_value,
177
25
                            description: tag_row.description,
178
25
                        }),
179
25
                    )
180
25
                })
181
                .collect();
182

            
183
        Ok(Some(CmdResult::TaggedEntities {
184
            entities: vec![(FinanceEntity::Account(account), tags)],
185
            pagination: None,
186
        }))
187
    }
188
230
}
189

            
190
command! {
191
    SetAccountTag {
192
        #[required]
193
        user_id: Uuid,
194
        #[required]
195
        account_id: Uuid,
196
        #[required]
197
        tag_name: String,
198
        #[required]
199
        tag_value: String,
200
        #[optional]
201
        description: String,
202
    } => {
203
        let user = User { id: user_id };
204
25
        let mut conn = user.get_connection().await.map_err(|err| {
205
25
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
206
25
            ConfigError::DB
207
25
        })?;
208

            
209
        let account_row = sqlx::query_file!("sql/select/accounts/by_id.sql", &account_id)
210
            .fetch_optional(&mut *conn)
211
            .await?;
212

            
213
        let Some(account_row) = account_row else {
214
            return Err(CmdError::Args("Account not found".to_string()));
215
        };
216

            
217
        let account = Account {
218
            id: account_row.id,
219
            parent: account_row.parent,
220
        };
221

            
222
        let desc = description.and_then(|text| {
223
            if text.trim().is_empty() {
224
                None
225
            } else {
226
                Some(text)
227
            }
228
        });
229

            
230
        let tag = Tag {
231
            id: Uuid::new_v4(),
232
            tag_name,
233
            tag_value,
234
            description: desc,
235
        };
236

            
237
        user.set_account_tag(&account, &tag).await?;
238

            
239
        Ok(Some(CmdResult::String("ok".to_string())))
240
    }
241
880
}
242

            
243
command! {
244
    GetAccount {
245
        #[required]
246
        user_id: Uuid,
247
        #[optional]
248
        account_id: Uuid,
249
        #[optional]
250
        account_name: String,
251
    } => {
252
        let user = User { id: user_id };
253
        let mut conn = user.get_connection().await.map_err(|err| {
254
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
255
            ConfigError::DB
256
        })?;
257

            
258
        // Get account by ID or name
259
        let mut account_id_option: Option<Uuid> = None;
260

            
261
        if let Some(aid) = account_id {
262
            account_id_option = Some(aid);
263
        } else if let Some(name) = account_name {
264
            // Search by name tag
265
            let account_row = sqlx::query_file!("sql/select/accounts/by_name.sql", &name)
266
                .fetch_optional(&mut *conn)
267
                .await?;
268

            
269
            if let Some(row) = account_row {
270
                account_id_option = Some(row.id);
271
            }
272
        } else {
273
            return Err(CmdError::Args(
274
                "Either account_id or account_name must be provided".to_string(),
275
            ));
276
        }
277

            
278
        // Now fetch the account by ID if we found one
279
        let account_query = if let Some(account_id) = account_id_option {
280
            sqlx::query_file!("sql/select/accounts/by_id.sql", &account_id)
281
                .fetch_optional(&mut *conn)
282
                .await?
283
        } else {
284
            None
285
        };
286

            
287
        // If account found, get its tags
288
        if let Some(row) = account_query {
289
            let account = Account {
290
                id: row.id,
291
                parent: row.parent,
292
            };
293

            
294
            // Get tags for this account
295
            let tags: HashMap<String, FinanceEntity> =
296
                sqlx::query_file!("sql/select/tags/by_account.sql", &account.id)
297
                    .fetch_all(&mut *conn)
298
                    .await?
299
                    .into_iter()
300
363
                    .map(|row| {
301
363
                        (
302
363
                            row.tag_name.clone(),
303
363
                            FinanceEntity::Tag(Tag {
304
363
                                id: row.id,
305
363
                                tag_name: row.tag_name,
306
363
                                tag_value: row.tag_value,
307
363
                                description: row.description,
308
363
                            }),
309
363
                        )
310
363
                    })
311
                    .collect();
312

            
313
            let tagged_account = vec![(FinanceEntity::Account(account), tags)];
314
            Ok(Some(CmdResult::TaggedEntities {
315
                entities: tagged_account,
316
                pagination: None,
317
            }))
318
        } else {
319
            // No account found
320
            Ok(Some(CmdResult::TaggedEntities {
321
                entities: vec![],
322
                pagination: None,
323
            }))
324
        }
325
    }
326
1820
}
327

            
328
command! {
329
    GetAccountCommodities {
330
        #[required]
331
        user_id: Uuid,
332
        #[required]
333
        account_id: Uuid,
334
    } => {
335
        let user = User { id: user_id };
336
        let mut conn = user.get_connection().await.map_err(|err| {
337
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
338
            ConfigError::DB
339
        })?;
340

            
341
        // Get unique commodities for this account
342
        let rows = sqlx::query_file!("sql/select/accounts/commodities.sql", &account_id)
343
            .fetch_all(&mut *conn)
344
            .await?;
345

            
346
        // Return the commodity information as structured data
347
        let mut commodity_infos = Vec::new();
348
        for row in rows {
349
            commodity_infos.push(CommodityInfo {
350
                commodity_id: row.commodity_id,
351
                symbol: row.symbol,
352
                name: row.commodity_name,
353
            });
354
        }
355

            
356
        Ok(Some(CmdResult::CommodityInfoList(commodity_infos)))
357
    }
358
2443
}
359

            
360
command! {
361
    GetBalance {
362
        #[required]
363
        user_id: Uuid,
364
        #[required]
365
        account_id: Uuid,
366
        #[optional]
367
        commodity_id: Uuid,
368
    } => {
369
        let user = User { id: user_id };
370
        let mut conn = user.get_connection().await.map_err(|err| {
371
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
372
            ConfigError::DB
373
        })?;
374

            
375
        // Get all splits with their conversion information
376
        let splits_data = sqlx::query_file!(
377
            "sql/balance/accounts/splits/all_with_conversion.sql",
378
            &account_id,
379
            commodity_id.as_ref()
380
        )
381
        .fetch_all(&mut *conn)
382
        .await?;
383

            
384
        if splits_data.is_empty() {
385
            // No splits - return zero balance as rational regardless of currency request
386
            return Ok(Some(CmdResult::Rational(Rational64::new(0, 1))));
387
        }
388

            
389
        // Check if all splits use the same commodity
390
        let unique_commodities: std::collections::HashSet<_> = splits_data.iter().map(|s| s.commodity_id).collect();
391

            
392
        match commodity_id {
393
            Some(target_commodity_id) => {
394
                // Single currency mode - convert everything to target commodity
395
                let mut total_balance = Rational64::new(0, 1);
396

            
397
                for split_data in splits_data {
398
                    let split_value = Rational64::new(split_data.value_num, split_data.value_denom);
399

            
400
                    if split_data.commodity_id == target_commodity_id {
401
                        // Same commodity, add directly
402
                        total_balance += split_value;
403
                    } else {
404
                        // Different commodity, need conversion
405
                        if let (Some(price_num), Some(price_denom)) = (split_data.price_num, split_data.price_denom) {
406
                            // Conversion data available
407
                            let price_ratio = Rational64::new(price_num, price_denom);
408
                            let converted_value = split_value * price_ratio;
409
                            total_balance += converted_value;
410
                        } else {
411
                            // Missing conversion data - get target commodity symbol for error
412
                            let to_symbol = sqlx::query_file_scalar!(
413
                                "sql/select/commodities/symbol.sql",
414
                                &target_commodity_id
415
                            )
416
                            .fetch_optional(&mut *conn)
417
                            .await?
418
                            .unwrap_or_else(|| target_commodity_id.to_string());
419

            
420
                            return Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
421
                                split_id: split_data.split_id,
422
                                from_commodity: split_data.commodity_symbol.clone(),
423
                                to_commodity: to_symbol,
424
                            })));
425
                        }
426
                    }
427
                }
428

            
429
                Ok(Some(CmdResult::Rational(total_balance)))
430
            },
431
            None => {
432
                // No specific currency requested
433
                if unique_commodities.len() == 1 {
434
                    // Single currency - return simple rational balance
435
                    let total_balance = splits_data.iter()
436
404
                        .map(|split_data| Rational64::new(split_data.value_num, split_data.value_denom))
437
                        .sum();
438
                    Ok(Some(CmdResult::Rational(total_balance)))
439
                } else {
440
                    // Multi-currency mode - return balance for each commodity
441
                    use std::collections::HashMap;
442
                    let mut balances_by_commodity: HashMap<Uuid, (Commodity, Rational64, String)> = HashMap::new();
443

            
444
                    for split_data in splits_data {
445
                        let split_value = Rational64::new(split_data.value_num, split_data.value_denom);
446

            
447
                        balances_by_commodity
448
                            .entry(split_data.commodity_id)
449
                            .and_modify(|(_, balance, _)| *balance += split_value)
450
60
                            .or_insert_with(|| {
451
60
                                let commodity = Commodity {
452
60
                                    id: split_data.commodity_id,
453
60
                                };
454
60
                                (commodity, split_value, split_data.commodity_symbol.clone())
455
60
                            });
456
                    }
457

            
458
                    // Convert to sorted vector (sort by symbol)
459
                    let mut result: Vec<(Commodity, Rational64)> = balances_by_commodity
460
                        .into_values()
461
60
                        .map(|(commodity, balance, _symbol)| (commodity, balance))
462
                        .collect();
463
30
                    result.sort_by(|a, b| {
464
                        // Sort by commodity_id since we don't have symbol in Commodity struct
465
30
                        a.0.id.cmp(&b.0.id)
466
30
                    });
467

            
468
                    Ok(Some(CmdResult::MultiCurrencyBalance(result)))
469
                }
470
            }
471
        }
472
    }
473
2035
}
474

            
475
#[cfg(test)]
476
mod command_tests {
477
    use super::*;
478
    use crate::{
479
        command::{commodity::CreateCommodity, transaction::CreateTransaction},
480
        db::DB_POOL,
481
    };
482
    use finance::{price::Price, split::Split};
483
    use sqlx::{
484
        PgPool,
485
        types::chrono::{DateTime, Utc},
486
    };
487
    use supp_macro::local_db_sqlx_test;
488
    use tokio::sync::OnceCell;
489

            
490
    /// Context for keeping environment intact
491
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
492
    static USER: OnceCell<User> = OnceCell::const_new();
493

            
494
21
    async fn setup() {
495
21
        CONTEXT
496
21
            .get_or_init(|| async {
497
                #[cfg(feature = "testlog")]
498
1
                let _ = env_logger::builder()
499
1
                    .is_test(true)
500
1
                    .filter_level(log::LevelFilter::Trace)
501
1
                    .try_init();
502
2
            })
503
21
            .await;
504
21
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
505
21
            .await;
506
21
    }
507

            
508
    #[local_db_sqlx_test]
509
    async fn test_create_account(pool: PgPool) -> anyhow::Result<()> {
510
        let user = USER.get().unwrap();
511
        user.commit()
512
            .await
513
            .expect("Failed to commit user to database");
514

            
515
        // First create a commodity
516
        let commodity_result = CreateCommodity::new()
517
            .symbol("TST".to_string())
518
            .name("Test Commodity".to_string())
519
            .user_id(user.id)
520
            .run()
521
            .await?;
522

            
523
        // Get the commodity ID and create a commodity entity
524
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
525
            uuid::Uuid::parse_str(&id)?
526
        } else {
527
            panic!("Expected commodity ID string result");
528
        };
529
        let _commodity = Commodity { id: commodity_id };
530

            
531
        // Now create an account
532
        if let Some(CmdResult::Entity(FinanceEntity::Account(account))) = CreateAccount::new()
533
            .name("Test Account".to_string())
534
            .user_id(user.id)
535
            .run()
536
            .await?
537
        {
538
            assert!(!account.id.is_nil());
539
        } else {
540
            panic!("Expected account ID string result");
541
        }
542
    }
543

            
544
    #[local_db_sqlx_test]
545
    async fn test_list_accounts_empty(pool: PgPool) -> anyhow::Result<()> {
546
        let user = USER.get().unwrap();
547
        user.commit()
548
            .await
549
            .expect("Failed to commit user to database");
550

            
551
        if let Some(CmdResult::TaggedEntities { entities, .. }) =
552
            ListAccounts::new().user_id(user.id).run().await?
553
        {
554
            assert!(
555
                entities.is_empty(),
556
                "Expected no accounts in empty database"
557
            );
558
        } else {
559
            panic!("Expected TaggedEntities result");
560
        }
561
    }
562

            
563
    #[local_db_sqlx_test]
564
    async fn test_list_accounts_with_data(pool: PgPool) -> anyhow::Result<()> {
565
        let user = USER.get().unwrap();
566
        user.commit()
567
            .await
568
            .expect("Failed to commit user to database");
569

            
570
        // First create a commodity
571
        let commodity_result = CreateCommodity::new()
572
            .symbol("TST".to_string())
573
            .name("Test Commodity".to_string())
574
            .user_id(user.id)
575
            .run()
576
            .await?;
577

            
578
        // Get the commodity ID and create a commodity entity
579
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
580
            uuid::Uuid::parse_str(&id)?
581
        } else {
582
            panic!("Expected commodity ID string result");
583
        };
584
        let _commodity = Commodity { id: commodity_id };
585

            
586
        // Create an account
587
        CreateAccount::new()
588
            .name("Test Account".to_string())
589
            .user_id(user.id)
590
            .run()
591
            .await?;
592

            
593
        // List accounts
594
        if let Some(CmdResult::TaggedEntities { entities, .. }) =
595
            ListAccounts::new().user_id(user.id).run().await?
596
        {
597
            assert_eq!(entities.len(), 1, "Expected one account");
598

            
599
            let (entity, tags) = &entities[0];
600
            if let FinanceEntity::Account(_) = entity {
601
                // Check tags
602
                assert_eq!(tags.len(), 1); // name tag
603
                if let FinanceEntity::Tag(tag) = &tags["name"] {
604
                    assert_eq!(tag.tag_name, "name");
605
                    assert_eq!(tag.tag_value, "Test Account");
606
                } else {
607
                    panic!("Expected Tag entity");
608
                }
609
            } else {
610
                panic!("Expected Account entity");
611
            }
612
        } else {
613
            panic!("Expected TaggedEntities result");
614
        }
615
    }
616

            
617
    #[local_db_sqlx_test]
618
    async fn test_get_account(pool: PgPool) -> anyhow::Result<()> {
619
        let user = USER.get().unwrap();
620
        user.commit()
621
            .await
622
            .expect("Failed to commit user to database");
623

            
624
        // First create a commodity
625
        let commodity_result = CreateCommodity::new()
626
            .symbol("TST".to_string())
627
            .name("Test Commodity".to_string())
628
            .user_id(user.id)
629
            .run()
630
            .await?;
631

            
632
        // Get the commodity ID
633
        let _commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
634
            uuid::Uuid::parse_str(&id)?
635
        } else {
636
            panic!("Expected commodity ID string result");
637
        };
638

            
639
        // Create an account
640
        let account_name = "Test Account";
641
        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
642
            CreateAccount::new()
643
                .name(account_name.to_string())
644
                .user_id(user.id)
645
                .run()
646
                .await?
647
        {
648
            account
649
        } else {
650
            panic!("Expected account entity result");
651
        };
652

            
653
        // Test GetAccount by ID
654
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
655
            .user_id(user.id)
656
            .account_id(account.id)
657
            .run()
658
            .await?
659
        {
660
            assert_eq!(entities.len(), 1, "Expected one account");
661

            
662
            let (entity, tags) = &entities[0];
663
            if let FinanceEntity::Account(a) = entity {
664
                assert_eq!(a.id, account.id);
665

            
666
                // Check tags
667
                assert_eq!(tags.len(), 1); // name tag
668
                if let FinanceEntity::Tag(tag) = &tags["name"] {
669
                    assert_eq!(tag.tag_name, "name");
670
                    assert_eq!(tag.tag_value, account_name);
671
                } else {
672
                    panic!("Expected Tag entity");
673
                }
674
            } else {
675
                panic!("Expected Account entity");
676
            }
677
        } else {
678
            panic!("Expected TaggedEntities result");
679
        }
680

            
681
        // Test GetAccount by name
682
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
683
            .user_id(user.id)
684
            .account_name(account_name.to_string())
685
            .run()
686
            .await?
687
        {
688
            assert_eq!(entities.len(), 1, "Expected one account");
689

            
690
            let (entity, _) = &entities[0];
691
            if let FinanceEntity::Account(a) = entity {
692
                assert_eq!(a.id, account.id);
693
            } else {
694
                panic!("Expected Account entity");
695
            }
696
        } else {
697
            panic!("Expected TaggedEntities result");
698
        }
699

            
700
        // Test with non-existent account ID
701
        let non_existent_id = Uuid::new_v4();
702
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
703
            .user_id(user.id)
704
            .account_id(non_existent_id)
705
            .run()
706
            .await?
707
        {
708
            assert_eq!(
709
                entities.len(),
710
                0,
711
                "Expected no accounts for non-existent ID"
712
            );
713
        } else {
714
            panic!("Expected empty TaggedEntities result");
715
        }
716

            
717
        // Test with non-existent account name
718
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
719
            .user_id(user.id)
720
            .account_name("Non-existent Account".to_string())
721
            .run()
722
            .await?
723
        {
724
            assert_eq!(
725
                entities.len(),
726
                0,
727
                "Expected no accounts for non-existent name"
728
            );
729
        } else {
730
            panic!("Expected empty TaggedEntities result");
731
        }
732
    }
733

            
734
    #[local_db_sqlx_test]
735
    async fn test_get_account_returns_parent(pool: PgPool) -> anyhow::Result<()> {
736
        let user = USER.get().unwrap();
737
        user.commit()
738
            .await
739
            .expect("Failed to commit user to database");
740

            
741
        let parent = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
742
            CreateAccount::new()
743
                .name("Parent".to_string())
744
                .user_id(user.id)
745
                .run()
746
                .await?
747
        {
748
            account
749
        } else {
750
            panic!("Expected account entity result");
751
        };
752

            
753
        let child = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
754
            CreateAccount::new()
755
                .name("Child".to_string())
756
                .user_id(user.id)
757
                .parent(parent.id)
758
                .run()
759
                .await?
760
        {
761
            account
762
        } else {
763
            panic!("Expected account entity result");
764
        };
765

            
766
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
767
            .user_id(user.id)
768
            .account_id(child.id)
769
            .run()
770
            .await?
771
        {
772
            let (entity, _) = &entities[0];
773
            if let FinanceEntity::Account(a) = entity {
774
                assert_eq!(
775
                    a.parent,
776
                    Some(parent.id),
777
                    "get-account must return the child's parent"
778
                );
779
            } else {
780
                panic!("Expected Account entity");
781
            }
782
        } else {
783
            panic!("Expected TaggedEntities result");
784
        }
785
    }
786

            
787
    #[local_db_sqlx_test]
788
    async fn test_get_account_commodities_no_transactions(pool: PgPool) -> anyhow::Result<()> {
789
        let user = USER.get().unwrap();
790
        user.commit()
791
            .await
792
            .expect("Failed to commit user to database");
793

            
794
        // Create a commodity
795
        let _commodity_result = CreateCommodity::new()
796
            .symbol("USD".to_string())
797
            .name("US Dollar".to_string())
798
            .user_id(user.id)
799
            .run()
800
            .await?;
801

            
802
        // Create an account with no transactions
803
        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
804
            CreateAccount::new()
805
                .name("Empty Account".to_string())
806
                .user_id(user.id)
807
                .run()
808
                .await?
809
        {
810
            account
811
        } else {
812
            panic!("Expected account entity result");
813
        };
814

            
815
        // Test GetAccountCommodities on account with no transactions
816
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
817
            .user_id(user.id)
818
            .account_id(account.id)
819
            .run()
820
            .await?
821
        {
822
            assert_eq!(
823
                commodities.len(),
824
                0,
825
                "Expected no commodities for account with no transactions"
826
            );
827
        } else {
828
            panic!("Expected CommodityInfoList result");
829
        }
830
    }
831

            
832
    #[local_db_sqlx_test]
833
    async fn test_get_account_commodities_single_commodity(pool: PgPool) -> anyhow::Result<()> {
834
        let user = USER.get().unwrap();
835
        user.commit()
836
            .await
837
            .expect("Failed to commit user to database");
838

            
839
        // Create a commodity
840
        let commodity_result = CreateCommodity::new()
841
            .symbol("EUR".to_string())
842
            .name("Euro".to_string())
843
            .user_id(user.id)
844
            .run()
845
            .await?;
846

            
847
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
848
            uuid::Uuid::parse_str(&id)?
849
        } else {
850
            panic!("Expected commodity ID string result");
851
        };
852

            
853
        // Create two accounts
854
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
855
            CreateAccount::new()
856
                .name("Account 1".to_string())
857
                .user_id(user.id)
858
                .run()
859
                .await?
860
        {
861
            account
862
        } else {
863
            panic!("Expected account entity result");
864
        };
865

            
866
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
867
            CreateAccount::new()
868
                .name("Account 2".to_string())
869
                .user_id(user.id)
870
                .run()
871
                .await?
872
        {
873
            account
874
        } else {
875
            panic!("Expected account entity result");
876
        };
877

            
878
        // Create a transaction with single commodity (EUR)
879
        let tx_id = Uuid::new_v4();
880
        let now = Utc::now();
881

            
882
        let split1 = Split {
883
            id: Uuid::new_v4(),
884
            tx_id,
885
            account_id: account1.id,
886
            commodity_id,
887
            value_num: -500,
888
            value_denom: 1,
889
            reconcile_state: None,
890
            reconcile_date: None,
891
            lot_id: None,
892
        };
893

            
894
        let split2 = Split {
895
            id: Uuid::new_v4(),
896
            tx_id,
897
            account_id: account2.id,
898
            commodity_id,
899
            value_num: 500,
900
            value_denom: 1,
901
            reconcile_state: None,
902
            reconcile_date: None,
903
            lot_id: None,
904
        };
905

            
906
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
907
        CreateTransaction::new()
908
            .user_id(user.id)
909
            .splits(splits)
910
            .id(tx_id)
911
            .post_date(now)
912
            .enter_date(now)
913
            .run()
914
            .await?;
915

            
916
        // Test GetAccountCommodities on account1 (should have one commodity)
917
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
918
            .user_id(user.id)
919
            .account_id(account1.id)
920
            .run()
921
            .await?
922
        {
923
            assert_eq!(
924
                commodities.len(),
925
                1,
926
                "Expected one commodity for account with single currency"
927
            );
928

            
929
            let commodity_info = &commodities[0];
930
            assert_eq!(commodity_info.commodity_id, commodity_id);
931
            assert_eq!(commodity_info.symbol, "EUR");
932
            assert_eq!(commodity_info.name, "Euro");
933
        } else {
934
            panic!("Expected CommodityInfoList result");
935
        }
936

            
937
        // Test GetAccountCommodities on account2 (should also have one commodity)
938
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
939
            .user_id(user.id)
940
            .account_id(account2.id)
941
            .run()
942
            .await?
943
        {
944
            assert_eq!(
945
                commodities.len(),
946
                1,
947
                "Expected one commodity for account with single currency"
948
            );
949

            
950
            let commodity_info = &commodities[0];
951
            assert_eq!(commodity_info.commodity_id, commodity_id);
952
            assert_eq!(commodity_info.symbol, "EUR");
953
            assert_eq!(commodity_info.name, "Euro");
954
        } else {
955
            panic!("Expected CommodityInfoList result");
956
        }
957
    }
958

            
959
    #[local_db_sqlx_test]
960
    async fn test_get_account_commodities_multiple_commodities(pool: PgPool) -> anyhow::Result<()> {
961
        let user = USER.get().unwrap();
962
        user.commit()
963
            .await
964
            .expect("Failed to commit user to database");
965

            
966
        // Create two commodities
967
        let usd_result = CreateCommodity::new()
968
            .symbol("USD".to_string())
969
            .name("US Dollar".to_string())
970
            .user_id(user.id)
971
            .run()
972
            .await?;
973

            
974
        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
975
            uuid::Uuid::parse_str(&id)?
976
        } else {
977
            panic!("Expected commodity ID string result");
978
        };
979

            
980
        let eur_result = CreateCommodity::new()
981
            .symbol("EUR".to_string())
982
            .name("Euro".to_string())
983
            .user_id(user.id)
984
            .run()
985
            .await?;
986

            
987
        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
988
            uuid::Uuid::parse_str(&id)?
989
        } else {
990
            panic!("Expected commodity ID string result");
991
        };
992

            
993
        // Create accounts
994
        let mixed_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
995
            CreateAccount::new()
996
                .name("Mixed Currency Account".to_string())
997
                .user_id(user.id)
998
                .run()
999
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let other_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Other Account".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        // Create first transaction with USD
        let tx1_id = Uuid::new_v4();
        let now = Utc::now();
        let splits1 = vec![
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx1_id,
                account_id: mixed_account.id,
                commodity_id: usd_id,
                value_num: 100,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx1_id,
                account_id: other_account.id,
                commodity_id: usd_id,
                value_num: -100,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits1)
            .id(tx1_id)
            .post_date(now)
            .enter_date(now)
            .run()
            .await?;
        // Create second transaction with EUR
        let tx2_id = Uuid::new_v4();
        let splits2 = vec![
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx2_id,
                account_id: mixed_account.id,
                commodity_id: eur_id,
                value_num: 200,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx2_id,
                account_id: other_account.id,
                commodity_id: eur_id,
                value_num: -200,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits2)
            .id(tx2_id)
            .post_date(now)
            .enter_date(now)
            .run()
            .await?;
        // Test GetAccountCommodities on mixed_account (should have two commodities)
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
            .user_id(user.id)
            .account_id(mixed_account.id)
            .run()
            .await?
        {
            assert_eq!(
                commodities.len(),
                2,
                "Expected two commodities for account with mixed currencies"
            );
            // Should be sorted by symbol (EUR comes before USD alphabetically)
            let eur_info = &commodities[0];
            assert_eq!(eur_info.commodity_id, eur_id);
            assert_eq!(eur_info.symbol, "EUR");
            assert_eq!(eur_info.name, "Euro");
            let usd_info = &commodities[1];
            assert_eq!(usd_info.commodity_id, usd_id);
            assert_eq!(usd_info.symbol, "USD");
            assert_eq!(usd_info.name, "US Dollar");
        } else {
            panic!("Expected CommodityInfoList result");
        }
        // Test GetAccountCommodities on other_account (should also have two commodities)
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
            .user_id(user.id)
            .account_id(other_account.id)
            .run()
            .await?
        {
            assert_eq!(
                commodities.len(),
                2,
                "Expected two commodities for account with mixed currencies"
            );
        } else {
            panic!("Expected CommodityInfoList result");
        }
    }
    #[local_db_sqlx_test]
    async fn test_get_account_commodities_error_cases(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // Test with non-existent account ID
        let non_existent_account_id = Uuid::new_v4();
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
            .user_id(user.id)
            .account_id(non_existent_account_id)
            .run()
            .await?
        {
            assert_eq!(
                commodities.len(),
                0,
                "Expected no commodities for non-existent account"
            );
        } else {
            panic!("Expected CommodityInfoList result");
        }
        // Test with non-existent user ID
        let non_existent_user_id = Uuid::new_v4();
        let result = GetAccountCommodities::new()
            .user_id(non_existent_user_id)
            .account_id(Uuid::new_v4())
            .run()
            .await;
        // Should succeed but return empty list since user isolation prevents access
        if let Ok(Some(CmdResult::CommodityInfoList(commodities))) = result {
            assert_eq!(
                commodities.len(),
                0,
                "Expected no commodities for non-existent user"
            );
        } else {
            // Or it might fail due to connection issues, which is also acceptable
            assert!(
                result.is_err(),
                "Expected error or empty result for non-existent user"
            );
        }
    }
    #[local_db_sqlx_test]
    async fn test_multi_currency_account_balance_with_price_conversion(pool: PgPool) {
        setup().await;
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // Step 1: Create two commodities (USD and EUR)
        let usd_result = CreateCommodity::new()
            .symbol("USD".to_string())
            .name("US Dollar".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let eur_result = CreateCommodity::new()
            .symbol("EUR".to_string())
            .name("Euro".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        // Extract commodity IDs
        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
            Uuid::parse_str(&id).unwrap()
        } else {
            panic!("Expected USD commodity ID");
        };
        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
            Uuid::parse_str(&id).unwrap()
        } else {
            panic!("Expected EUR commodity ID");
        };
        // Step 2: Create two accounts
        let account1_result = CreateAccount::new()
            .name("USD Account".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let account2_result = CreateAccount::new()
            .name("EUR Account".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        // Extract account IDs
        let account1_id =
            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account1_result {
                acc.id
            } else {
                panic!("Expected USD account");
            };
        let account2_id =
            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account2_result {
                acc.id
            } else {
                panic!("Expected EUR account");
            };
        // Step 3: Create single-currency transactions first to test basic functionality
        let tx1_id = Uuid::new_v4();
        let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap(); // Fixed timestamp
        let tx1_split1 = Split {
            id: Uuid::new_v4(),
            tx_id: tx1_id,
            account_id: account1_id,
            commodity_id: usd_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let tx1_split2 = Split {
            id: Uuid::new_v4(),
            tx_id: tx1_id,
            account_id: account2_id,
            commodity_id: usd_id, // Same currency to make transaction balance
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let tx1_splits = vec![
            FinanceEntity::Split(tx1_split1),
            FinanceEntity::Split(tx1_split2),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(tx1_splits)
            .id(tx1_id)
            .post_date(now)
            .enter_date(now)
            .note("First USD transaction".to_string())
            .run()
            .await
            .unwrap();
        // Create second transaction in EUR currency
        let tx2_id = Uuid::new_v4();
        let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); // 10 minutes later
        let tx2_split1 = Split {
            id: Uuid::new_v4(),
            tx_id: tx2_id,
            account_id: account1_id,
            commodity_id: eur_id,
            value_num: -85,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let tx2_split2 = Split {
            id: Uuid::new_v4(),
            tx_id: tx2_id,
            account_id: account2_id,
            commodity_id: eur_id,
            value_num: 85,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let tx2_splits = vec![
            FinanceEntity::Split(tx2_split1),
            FinanceEntity::Split(tx2_split2),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(tx2_splits)
            .id(tx2_id)
            .post_date(later)
            .enter_date(later)
            .note("Second EUR transaction".to_string())
            .run()
            .await
            .unwrap();
        // Create price conversion data between EUR and USD
        let price1 = Price {
            id: Uuid::new_v4(),
            date: now,
            commodity_id: eur_id,
            currency_id: usd_id,
            commodity_split: None, // General price, not tied to specific splits
            currency_split: None,
            value_num: 1176, // 1.176 USD per EUR (as rational: 1176/1000)
            value_denom: 1000,
        };
        // Insert price manually using raw SQL since we don't have a CreatePrice command
        let mut conn = user.get_connection().await.unwrap();
        sqlx::query_file!(
            "sql/insert/prices/price.sql",
            price1.id,
            price1.commodity_id,
            price1.currency_id,
            price1.commodity_split,
            price1.currency_split,
            price1.date,
            price1.value_num,
            price1.value_denom
        )
        .execute(&mut *conn)
        .await
        .unwrap();
        // Test Account1 balance (mixed currencies - should return MultiCurrencyBalance without commodity_id)
        let balance_result1 = GetBalance::new()
            .user_id(user.id)
            .account_id(account1_id)
            .run()
            .await
            .unwrap();
        // Should return MultiCurrencyBalance due to mixed currencies
        match balance_result1 {
            Some(CmdResult::MultiCurrencyBalance(balances)) => {
                assert_eq!(
                    balances.len(),
                    2,
                    "Account1 should have two currency balances"
                );
            }
            _ => panic!("Expected MultiCurrencyBalance result for account1"),
        }
        // Test Account1 balance in USD (should fail due to missing split-specific conversion)
        let balance_result2 = GetBalance::new()
            .user_id(user.id)
            .account_id(account1_id)
            .commodity_id(usd_id)
            .run()
            .await;
        // Should fail because the price record is not split-specific
        assert!(
            balance_result2.is_err(),
            "Expected error for missing split-specific EUR->USD conversion"
        );
        // Verify it's the right kind of error
        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
            from_commodity,
            to_commodity,
            ..
        }))) = balance_result2
        {
            assert_eq!(from_commodity, "EUR");
            assert_eq!(to_commodity, "USD");
        } else {
            panic!("Expected MissingConversion error");
        }
        // Test Account2 balance (mixed currencies - should return MultiCurrencyBalance without commodity_id)
        let balance_result3 = GetBalance::new()
            .user_id(user.id)
            .account_id(account2_id)
            .run()
            .await
            .unwrap();
        // Should return MultiCurrencyBalance due to mixed currencies
        match balance_result3 {
            Some(CmdResult::MultiCurrencyBalance(balances)) => {
                assert_eq!(
                    balances.len(),
                    2,
                    "Account2 should have two currency balances"
                );
            }
            _ => panic!("Expected MultiCurrencyBalance result for account2"),
        }
        // Test Account2 balance in EUR (should fail due to missing split-specific conversion)
        let balance_result4 = GetBalance::new()
            .user_id(user.id)
            .account_id(account2_id)
            .commodity_id(eur_id)
            .run()
            .await;
        // Should fail because USD->EUR conversion is not available (split-specific)
        assert!(
            balance_result4.is_err(),
            "Expected error for missing split-specific USD->EUR conversion"
        );
        // Verify it's the right kind of error
        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
            from_commodity,
            to_commodity,
            ..
        }))) = balance_result4
        {
            assert_eq!(from_commodity, "USD");
            assert_eq!(to_commodity, "EUR");
        } else {
            panic!("Expected MissingConversion error");
        }
        // Test currency conversion - get Account2 balance in USD (should fail due to missing split-specific conversion)
        let balance_result5 = GetBalance::new()
            .user_id(user.id)
            .account_id(account2_id)
            .commodity_id(usd_id) // Convert to USD
            .run()
            .await;
        // Should fail because EUR->USD conversion is not available (split-specific)
        assert!(
            balance_result5.is_err(),
            "Expected error for missing split-specific EUR->USD conversion"
        );
        // Verify it's the right kind of error
        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
            from_commodity,
            to_commodity,
            ..
        }))) = balance_result5
        {
            assert_eq!(from_commodity, "EUR");
            assert_eq!(to_commodity, "USD");
        } else {
            panic!("Expected MissingConversion error for USD conversion");
        }
    }
    #[local_db_sqlx_test]
    async fn test_account_balance_without_price_data(pool: PgPool) {
        setup().await;
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // Create commodities and accounts
        let usd_result = CreateCommodity::new()
            .symbol("USD".to_string())
            .name("US Dollar".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let eur_result = CreateCommodity::new()
            .symbol("EUR".to_string())
            .name("Euro".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
            Uuid::parse_str(&id).unwrap()
        } else {
            panic!("Expected USD commodity ID");
        };
        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
            Uuid::parse_str(&id).unwrap()
        } else {
            panic!("Expected EUR commodity ID");
        };
        let account_result = CreateAccount::new()
            .name("Mixed Account".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let account_id =
            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account_result {
                acc.id
            } else {
                panic!("Expected account");
            };
        // Create a second account to make balanced transactions
        let account2_result = CreateAccount::new()
            .name("Second Account".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let account2_id =
            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account2_result {
                acc.id
            } else {
                panic!("Expected second account");
            };
        // Create first transaction (USD) WITHOUT price data
        let tx1_id = Uuid::new_v4();
        let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id: tx1_id,
            account_id,
            commodity_id: usd_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split1_balance = Split {
            id: Uuid::new_v4(),
            tx_id: tx1_id,
            account_id: account2_id,
            commodity_id: usd_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits1 = vec![
            FinanceEntity::Split(split1),
            FinanceEntity::Split(split1_balance),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits1)
            .id(tx1_id)
            .post_date(now)
            .enter_date(now)
            .note("USD transaction without price data".to_string())
            .run()
            .await
            .unwrap();
        // Create second transaction (EUR) WITHOUT price data
        let tx2_id = Uuid::new_v4();
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id: tx2_id,
            account_id,
            commodity_id: eur_id,
            value_num: -85,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2_balance = Split {
            id: Uuid::new_v4(),
            tx_id: tx2_id,
            account_id: account2_id,
            commodity_id: eur_id,
            value_num: 85,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits2 = vec![
            FinanceEntity::Split(split2),
            FinanceEntity::Split(split2_balance),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits2)
            .id(tx2_id)
            .post_date(now)
            .enter_date(now)
            .note("EUR transaction without price data".to_string())
            .run()
            .await
            .unwrap();
        // Test balance calculation - should fail due to missing conversion
        let balance_result = GetBalance::new()
            .user_id(user.id)
            .account_id(account_id)
            .commodity_id(usd_id) // Try to get balance in USD
            .run()
            .await;
        // Should fail due to missing EUR->USD price conversion
        assert!(
            balance_result.is_err(),
            "Expected error for missing EUR->USD conversion"
        );
        // Verify it's the right kind of error
        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
            from_commodity,
            to_commodity,
            ..
        }))) = balance_result
        {
            assert_eq!(from_commodity, "EUR");
            assert_eq!(to_commodity, "USD");
        } else {
            panic!("Expected MissingConversion error");
        }
    }
    #[local_db_sqlx_test]
    async fn test_get_balance_single_currency(pool: PgPool) {
        setup().await;
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // Create a commodity
        let commodity_result = CreateCommodity::new()
            .symbol("USD".to_string())
            .name("US Dollar".to_string())
            .user_id(user.id)
            .run()
            .await
            .expect("Failed to create commodity");
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            Uuid::parse_str(&id).expect("Failed to parse commodity ID")
        } else {
            panic!("Expected commodity ID string result");
        };
        // Create two accounts
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Test Account 1".to_string())
                .user_id(user.id)
                .run()
                .await
                .expect("Test operation failed")
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Test Account 2".to_string())
                .user_id(user.id)
                .run()
                .await
                .expect("Test operation failed")
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        // Create a transaction: 100 USD from account1 to account2
        let tx_id = Uuid::new_v4();
        let now = Utc::now();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .run()
            .await
            .expect("Test operation failed");
        // Test balance for account1 (should be -100)
        let balance_result1 = GetBalance::new()
            .user_id(user.id)
            .account_id(account1.id)
            .run()
            .await
            .expect("Test operation failed");
        if let Some(CmdResult::Rational(balance)) = balance_result1 {
            assert_eq!(
                balance,
                Rational64::new(-100, 1),
                "Account1 balance should be -100, got: {balance}"
            );
        } else {
            panic!("Expected rational balance result for account1");
        }
        // Test balance for account2 (should be 100)
        let balance_result2 = GetBalance::new()
            .user_id(user.id)
            .account_id(account2.id)
            .run()
            .await
            .expect("Test operation failed");
        if let Some(CmdResult::Rational(balance)) = balance_result2 {
            assert_eq!(
                balance,
                Rational64::new(100, 1),
                "Account2 balance should be 100, got: {balance}"
            );
        } else {
            panic!("Expected rational balance result for account2");
        }
        // Test balance with explicit commodity_id (should work the same)
        let balance_result3 = GetBalance::new()
            .user_id(user.id)
            .account_id(account1.id)
            .commodity_id(commodity_id)
            .run()
            .await
            .expect("Test operation failed");
        if let Some(CmdResult::Rational(balance)) = balance_result3 {
            assert_eq!(
                balance,
                Rational64::new(-100, 1),
                "Account1 balance with explicit commodity_id should be -100, got: {balance}"
            );
        } else {
            panic!("Expected rational balance result with explicit commodity_id");
        }
    }
    #[local_db_sqlx_test]
    async fn test_get_balance_empty_account(pool: PgPool) {
        setup().await;
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // Create an account with no transactions
        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Empty Account".to_string())
                .user_id(user.id)
                .run()
                .await
                .expect("Test operation failed")
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        // Test balance for empty account (should be 0)
        let balance_result = GetBalance::new()
            .user_id(user.id)
            .account_id(account.id)
            .run()
            .await
            .expect("Test operation failed");
        if let Some(CmdResult::Rational(balance)) = balance_result {
            assert_eq!(
                balance,
                Rational64::new(0, 1),
                "Empty account balance should be 0, got: {balance}"
            );
        } else {
            panic!("Expected rational balance result for empty account");
        }
    }
    #[local_db_sqlx_test]
    async fn test_get_balance_mixed_currencies_error(pool: PgPool) {
        setup().await;
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // Create two commodities
        let usd_result = CreateCommodity::new()
            .symbol("USD".to_string())
            .name("US Dollar".to_string())
            .user_id(user.id)
            .run()
            .await
            .expect("Test operation failed");
        let eur_result = CreateCommodity::new()
            .symbol("EUR".to_string())
            .name("Euro".to_string())
            .user_id(user.id)
            .run()
            .await
            .expect("Test operation failed");
        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
            Uuid::parse_str(&id).expect("Failed to parse USD commodity ID")
        } else {
            panic!("Expected USD commodity ID");
        };
        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
            Uuid::parse_str(&id).expect("Failed to parse EUR commodity ID")
        } else {
            panic!("Expected EUR commodity ID");
        };
        // Create three accounts
        let mixed_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Mixed Currency Account".to_string())
                .user_id(user.id)
                .run()
                .await
                .expect("Test operation failed")
        {
            account
        } else {
            panic!("Expected mixed account entity result");
        };
        let usd_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("USD Account".to_string())
                .user_id(user.id)
                .run()
                .await
                .expect("Test operation failed")
        {
            account
        } else {
            panic!("Expected USD account entity result");
        };
        let eur_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("EUR Account".to_string())
                .user_id(user.id)
                .run()
                .await
                .expect("Test operation failed")
        {
            account
        } else {
            panic!("Expected EUR account entity result");
        };
        // Create first transaction: 100 USD to mixed_account
        let tx1_id = Uuid::new_v4();
        let now = Utc::now();
        let splits1 = vec![
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx1_id,
                account_id: mixed_account.id,
                commodity_id: usd_id,
                value_num: 100,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx1_id,
                account_id: usd_account.id,
                commodity_id: usd_id,
                value_num: -100,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits1)
            .id(tx1_id)
            .post_date(now)
            .enter_date(now)
            .run()
            .await
            .expect("Test operation failed");
        // Create second transaction: 50 EUR to mixed_account
        let tx2_id = Uuid::new_v4();
        let splits2 = vec![
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx2_id,
                account_id: mixed_account.id,
                commodity_id: eur_id,
                value_num: 50,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx2_id,
                account_id: eur_account.id,
                commodity_id: eur_id,
                value_num: -50,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits2)
            .id(tx2_id)
            .post_date(now)
            .enter_date(now)
            .run()
            .await
            .expect("Test operation failed");
        // Test balance for mixed_account without commodity_id (should return MultiCurrencyBalance)
        let balance_result = GetBalance::new()
            .user_id(user.id)
            .account_id(mixed_account.id)
            .run()
            .await
            .expect("Test operation failed");
        if let Some(CmdResult::MultiCurrencyBalance(balances)) = balance_result {
            assert_eq!(balances.len(), 2, "Expected two currency balances");
            // USD balance should be 100, EUR balance should be 50
            for (commodity, balance) in balances {
                match balance.to_integer() {
                    100 => {
                        // USD balance
                        assert_eq!(commodity.id, usd_id);
                    }
                    50 => {
                        // EUR balance
                        assert_eq!(commodity.id, eur_id);
                    }
                    _ => panic!("Unexpected balance: {balance}"),
                }
            }
        } else {
            panic!("Expected MultiCurrencyBalance result for mixed currencies");
        }
        // Test balance with specific commodity_id (should error due to missing conversion)
        let balance_result_usd = GetBalance::new()
            .user_id(user.id)
            .account_id(mixed_account.id)
            .commodity_id(usd_id)
            .run()
            .await;
        // Should fail due to missing EUR->USD price conversion
        assert!(
            balance_result_usd.is_err(),
            "Expected error for missing EUR->USD conversion"
        );
        // Verify it's the right kind of error
        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
            from_commodity,
            to_commodity,
            ..
        }))) = balance_result_usd
        {
            assert_eq!(from_commodity, "EUR");
            assert_eq!(to_commodity, "USD");
        } else {
            panic!("Expected MissingConversion error");
        }
    }
    #[local_db_sqlx_test]
    async fn test_cross_account_balance_isolation(pool: PgPool) {
        setup().await;
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // Create commodities
        let usd_result = CreateCommodity::new()
            .symbol("USD".to_string())
            .name("US Dollar".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let eur_result = CreateCommodity::new()
            .symbol("EUR".to_string())
            .name("Euro".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
            Uuid::parse_str(&id).unwrap()
        } else {
            panic!("Expected USD commodity ID");
        };
        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
            Uuid::parse_str(&id).unwrap()
        } else {
            panic!("Expected EUR commodity ID");
        };
        // Create two accounts
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) =
            CreateAccount::new()
                .name("Account1".to_string())
                .user_id(user.id)
                .run()
                .await
                .unwrap()
        {
            acc
        } else {
            panic!("Expected account1");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) =
            CreateAccount::new()
                .name("Account2".to_string())
                .user_id(user.id)
                .run()
                .await
                .unwrap()
        {
            acc
        } else {
            panic!("Expected account2");
        };
        // Transaction 1: 100 USD from account1 to account2
        let tx1_id = Uuid::new_v4();
        let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap();
        let splits1 = vec![
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx1_id,
                account_id: account1.id,
                commodity_id: usd_id,
                value_num: -100,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx1_id,
                account_id: account2.id,
                commodity_id: usd_id,
                value_num: 100,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits1)
            .id(tx1_id)
            .post_date(now)
            .enter_date(now)
            .run()
            .await
            .unwrap();
        // Check initial balances (should be simple single-currency)
        let balance1_initial = GetBalance::new()
            .user_id(user.id)
            .account_id(account1.id)
            .run()
            .await
            .unwrap();
        let balance2_initial = GetBalance::new()
            .user_id(user.id)
            .account_id(account2.id)
            .run()
            .await
            .unwrap();
        // Both should have simple USD balances
        match balance1_initial {
            Some(CmdResult::Rational(balance)) => {
                assert_eq!(balance, Rational64::new(-100, 1));
            }
            _ => panic!("Expected Rational balance result for account1"),
        }
        match balance2_initial {
            Some(CmdResult::Rational(balance)) => {
                assert_eq!(balance, Rational64::new(100, 1));
            }
            _ => panic!("Expected Rational balance result for account2"),
        }
        // Transaction 2: Add EUR transaction - 50 EUR from account1 to account2
        let tx2_id = Uuid::new_v4();
        let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); // 10 minutes later
        let splits2 = vec![
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx2_id,
                account_id: account1.id,
                commodity_id: eur_id,
                value_num: -50,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx2_id,
                account_id: account2.id,
                commodity_id: eur_id,
                value_num: 50,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits2)
            .id(tx2_id)
            .post_date(later)
            .enter_date(later)
            .run()
            .await
            .unwrap();
        // Now both accounts have mixed currencies and should return MultiCurrencyBalance
        let balance1_after = GetBalance::new()
            .user_id(user.id)
            .account_id(account1.id)
            .run()
            .await
            .unwrap();
        let balance2_after = GetBalance::new()
            .user_id(user.id)
            .account_id(account2.id)
            .run()
            .await
            .unwrap();
        // Both should return MultiCurrencyBalance due to mixed currencies
        match balance1_after {
            Some(CmdResult::MultiCurrencyBalance(balances)) => {
                assert_eq!(
                    balances.len(),
                    2,
                    "Account1 should have two currency balances"
                );
            }
            _ => panic!("Expected MultiCurrencyBalance result for account1"),
        }
        match balance2_after {
            Some(CmdResult::MultiCurrencyBalance(balances)) => {
                assert_eq!(
                    balances.len(),
                    2,
                    "Account2 should have two currency balances"
                );
            }
            _ => panic!("Expected MultiCurrencyBalance result for account2"),
        }
        // Test Account2 balance in EUR before adding account1-only transaction
        let balance2_eur_before = GetBalance::new()
            .user_id(user.id)
            .account_id(account2.id)
            .commodity_id(eur_id)
            .run()
            .await;
        // Should fail due to missing USD->EUR price conversion
        assert!(
            balance2_eur_before.is_err(),
            "Expected error for missing USD->EUR conversion"
        );
        // Verify it's the right kind of error
        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
            from_commodity,
            to_commodity,
            ..
        }))) = balance2_eur_before
        {
            assert_eq!(from_commodity, "USD");
            assert_eq!(to_commodity, "EUR");
        } else {
            panic!("Expected MissingConversion error");
        }
        // Now add a third transaction affecting only account1 (not account2)
        let tx3_id = Uuid::new_v4();
        let even_later = DateTime::<Utc>::from_timestamp(1640996400, 0).unwrap(); // 20 minutes later
        let account3 = if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) =
            CreateAccount::new()
                .name("Account3".to_string())
                .user_id(user.id)
                .run()
                .await
                .unwrap()
        {
            acc
        } else {
            panic!("Expected account3");
        };
        let splits3 = vec![
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx3_id,
                account_id: account1.id,
                commodity_id: usd_id,
                value_num: -25,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
            FinanceEntity::Split(Split {
                id: Uuid::new_v4(),
                tx_id: tx3_id,
                account_id: account3.id,
                commodity_id: usd_id,
                value_num: 25,
                value_denom: 1,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            }),
        ];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits3)
            .id(tx3_id)
            .post_date(even_later)
            .enter_date(even_later)
            .run()
            .await
            .unwrap();
        // CRITICAL TEST: Account2's balance should still fail due to missing USD->EUR conversion
        // This verifies that account1's new transaction doesn't affect account2's error condition
        let balance2_eur_after = GetBalance::new()
            .user_id(user.id)
            .account_id(account2.id)
            .commodity_id(eur_id)
            .run()
            .await;
        // Should still fail due to missing USD->EUR price conversion, unchanged by account1's new transaction
        assert!(
            balance2_eur_after.is_err(),
            "Expected error for missing USD->EUR conversion (account isolation test)"
        );
        // Verify it's still the same kind of error
        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
            from_commodity,
            to_commodity,
            ..
        }))) = balance2_eur_after
        {
            assert_eq!(from_commodity, "USD");
            assert_eq!(to_commodity, "EUR");
        } else {
            panic!("Expected MissingConversion error");
        }
    }
}