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
256
}
32

            
33
command! {
34
    ListAccounts {
35
        #[required]
36
        user_id: Uuid,
37
    } => {
38
        let user = User { id: user_id };
39
12
        let mut conn = user.get_connection().await.map_err(|err| {
40
12
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
41
12
            ConfigError::DB
42
12
        })?;
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
            // Build the account locally (you still have `commodity` in a local variable).
52
            let account = Account::builder()
53
                .id(row.id)
54
                .build()
55
                .expect("Account built with all required fields");
56

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

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

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

            
87
command! {
88
    GetAccount {
89
        #[required]
90
        user_id: Uuid,
91
        #[optional]
92
        account_id: Uuid,
93
        #[optional]
94
        account_name: String,
95
    } => {
96
        let user = User { id: user_id };
97
        let mut conn = user.get_connection().await.map_err(|err| {
98
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
99
            ConfigError::DB
100
        })?;
101

            
102
        // Get account by ID or name
103
        let mut account_id_option: Option<Uuid> = None;
104

            
105
        if let Some(aid) = account_id {
106
            account_id_option = Some(aid);
107
        } else if let Some(name) = account_name {
108
            // Search by name tag
109
            let account_row = sqlx::query_file!("sql/select/accounts/by_name.sql", &name)
110
                .fetch_optional(&mut *conn)
111
                .await?;
112

            
113
            if let Some(row) = account_row {
114
                account_id_option = Some(row.id);
115
            }
116
        } else {
117
            return Err(CmdError::Args(
118
                "Either account_id or account_name must be provided".to_string(),
119
            ));
120
        }
121

            
122
        // Now fetch the account by ID if we found one
123
        let account_query = if let Some(account_id) = account_id_option {
124
            sqlx::query_file!("sql/select/accounts/by_id.sql", &account_id)
125
                .fetch_optional(&mut *conn)
126
                .await?
127
        } else {
128
            None
129
        };
130

            
131
        // If account found, get its tags
132
        if let Some(row) = account_query {
133
            let account = Account::builder()
134
                .id(row.id)
135
                .build()
136
                .expect("Account built with all required fields");
137

            
138
            // Get tags for this account
139
            let tags: HashMap<String, FinanceEntity> =
140
                sqlx::query_file!("sql/select/tags/by_account.sql", &account.id)
141
                    .fetch_all(&mut *conn)
142
                    .await?
143
                    .into_iter()
144
2
                    .map(|row| {
145
2
                        (
146
2
                            row.tag_name.clone(),
147
2
                            FinanceEntity::Tag(Tag {
148
2
                                id: row.id,
149
2
                                tag_name: row.tag_name,
150
2
                                tag_value: row.tag_value,
151
2
                                description: row.description,
152
2
                            }),
153
2
                        )
154
2
                    })
155
                    .collect();
156

            
157
            let tagged_account = vec![(FinanceEntity::Account(account), tags)];
158
            Ok(Some(CmdResult::TaggedEntities {
159
                entities: tagged_account,
160
                pagination: None,
161
            }))
162
        } else {
163
            // No account found
164
            Ok(Some(CmdResult::TaggedEntities {
165
                entities: vec![],
166
                pagination: None,
167
            }))
168
        }
169
    }
170
16
}
171

            
172
command! {
173
    GetAccountCommodities {
174
        #[required]
175
        user_id: Uuid,
176
        #[required]
177
        account_id: Uuid,
178
    } => {
179
        let user = User { id: user_id };
180
        let mut conn = user.get_connection().await.map_err(|err| {
181
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
182
            ConfigError::DB
183
        })?;
184

            
185
        // Get unique commodities for this account
186
        let rows = sqlx::query_file!("sql/select/accounts/commodities.sql", &account_id)
187
            .fetch_all(&mut *conn)
188
            .await?;
189

            
190
        // Return the commodity information as structured data
191
        let mut commodity_infos = Vec::new();
192
        for row in rows {
193
            commodity_infos.push(CommodityInfo {
194
                commodity_id: row.commodity_id,
195
                symbol: row.symbol,
196
                name: row.commodity_name,
197
            });
198
        }
199

            
200
        Ok(Some(CmdResult::CommodityInfoList(commodity_infos)))
201
    }
202
28
}
203

            
204
command! {
205
    GetBalance {
206
        #[required]
207
        user_id: Uuid,
208
        #[required]
209
        account_id: Uuid,
210
        #[optional]
211
        commodity_id: Uuid,
212
    } => {
213
        let user = User { id: user_id };
214
        let mut conn = user.get_connection().await.map_err(|err| {
215
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
216
            ConfigError::DB
217
        })?;
218

            
219
        // Get all splits with their conversion information
220
        let splits_data = sqlx::query_file!(
221
            "sql/balance/accounts/splits/all_with_conversion.sql",
222
            &account_id,
223
            commodity_id.as_ref()
224
        )
225
        .fetch_all(&mut *conn)
226
        .await?;
227

            
228
        if splits_data.is_empty() {
229
            // No splits - return zero balance as rational regardless of currency request
230
            return Ok(Some(CmdResult::Rational(Rational64::new(0, 1))));
231
        }
232

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

            
236
        match commodity_id {
237
            Some(target_commodity_id) => {
238
                // Single currency mode - convert everything to target commodity
239
                let mut total_balance = Rational64::new(0, 1);
240

            
241
                for split_data in splits_data {
242
                    let split_value = Rational64::new(split_data.value_num, split_data.value_denom);
243

            
244
                    if split_data.commodity_id == target_commodity_id {
245
                        // Same commodity, add directly
246
                        total_balance += split_value;
247
                    } else {
248
                        // Different commodity, need conversion
249
                        if let (Some(price_num), Some(price_denom)) = (split_data.price_num, split_data.price_denom) {
250
                            // Conversion data available
251
                            let price_ratio = Rational64::new(price_num, price_denom);
252
                            let converted_value = split_value * price_ratio;
253
                            total_balance += converted_value;
254
                        } else {
255
                            // Missing conversion data - get target commodity symbol for error
256
                            let to_symbol = sqlx::query_file_scalar!(
257
                                "sql/select/commodities/symbol.sql",
258
                                &target_commodity_id
259
                            )
260
                            .fetch_optional(&mut *conn)
261
                            .await?
262
                            .unwrap_or_else(|| target_commodity_id.to_string());
263

            
264
                            return Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
265
                                split_id: split_data.split_id,
266
                                from_commodity: split_data.commodity_symbol.clone(),
267
                                to_commodity: to_symbol,
268
                            })));
269
                        }
270
                    }
271
                }
272

            
273
                Ok(Some(CmdResult::Rational(total_balance)))
274
            },
275
            None => {
276
                // No specific currency requested
277
                if unique_commodities.len() == 1 {
278
                    // Single currency - return simple rational balance
279
                    let total_balance = splits_data.iter()
280
4
                        .map(|split_data| Rational64::new(split_data.value_num, split_data.value_denom))
281
                        .sum();
282
                    Ok(Some(CmdResult::Rational(total_balance)))
283
                } else {
284
                    // Multi-currency mode - return balance for each commodity
285
                    use std::collections::HashMap;
286
                    let mut balances_by_commodity: HashMap<Uuid, (Commodity, Rational64, String)> = HashMap::new();
287

            
288
                    for split_data in splits_data {
289
                        let split_value = Rational64::new(split_data.value_num, split_data.value_denom);
290

            
291
                        balances_by_commodity
292
                            .entry(split_data.commodity_id)
293
                            .and_modify(|(_, balance, _)| *balance += split_value)
294
10
                            .or_insert_with(|| {
295
10
                                let commodity = Commodity {
296
10
                                    id: split_data.commodity_id,
297
10
                                    fraction: split_data.commodity_fraction,
298
10
                                };
299
10
                                (commodity, split_value, split_data.commodity_symbol.clone())
300
10
                            });
301
                    }
302

            
303
                    // Convert to sorted vector (sort by symbol)
304
                    let mut result: Vec<(Commodity, Rational64)> = balances_by_commodity
305
                        .into_values()
306
10
                        .map(|(commodity, balance, _symbol)| (commodity, balance))
307
                        .collect();
308
5
                    result.sort_by(|a, b| {
309
                        // Sort by commodity_id since we don't have symbol in Commodity struct
310
5
                        a.0.id.cmp(&b.0.id)
311
5
                    });
312

            
313
                    Ok(Some(CmdResult::MultiCurrencyBalance(result)))
314
                }
315
            }
316
        }
317
    }
318
80
}
319

            
320
#[cfg(test)]
321
mod command_tests {
322
    use super::*;
323
    use crate::{
324
        command::{commodity::CreateCommodity, transaction::CreateTransaction},
325
        db::DB_POOL,
326
    };
327
    use finance::{price::Price, split::Split};
328
    use sqlx::{
329
        PgPool,
330
        types::chrono::{DateTime, Utc},
331
    };
332
    use supp_macro::local_db_sqlx_test;
333
    use tokio::sync::OnceCell;
334

            
335
    /// Context for keeping environment intact
336
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
337
    static USER: OnceCell<User> = OnceCell::const_new();
338

            
339
20
    async fn setup() {
340
20
        CONTEXT
341
20
            .get_or_init(|| async {
342
                #[cfg(feature = "testlog")]
343
1
                let _ = env_logger::builder()
344
1
                    .is_test(true)
345
1
                    .filter_level(log::LevelFilter::Trace)
346
1
                    .try_init();
347
2
            })
348
20
            .await;
349
20
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
350
20
            .await;
351
20
    }
352

            
353
    #[local_db_sqlx_test]
354
    async fn test_create_account(pool: PgPool) -> anyhow::Result<()> {
355
        let user = USER.get().unwrap();
356
        user.commit()
357
            .await
358
            .expect("Failed to commit user to database");
359

            
360
        // First create a commodity
361
        let commodity_result = CreateCommodity::new()
362
            .fraction(1.into())
363
            .symbol("TST".to_string())
364
            .name("Test Commodity".to_string())
365
            .user_id(user.id)
366
            .run()
367
            .await?;
368

            
369
        // Get the commodity ID and create a commodity entity
370
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
371
            uuid::Uuid::parse_str(&id)?
372
        } else {
373
            panic!("Expected commodity ID string result");
374
        };
375
        let _commodity = Commodity {
376
            id: commodity_id,
377
            fraction: 1,
378
        };
379

            
380
        // Now create an account
381
        if let Some(CmdResult::Entity(FinanceEntity::Account(account))) = CreateAccount::new()
382
            .name("Test Account".to_string())
383
            .user_id(user.id)
384
            .run()
385
            .await?
386
        {
387
            assert!(!account.id.is_nil());
388
        } else {
389
            panic!("Expected account ID string result");
390
        }
391
    }
392

            
393
    #[local_db_sqlx_test]
394
    async fn test_list_accounts_empty(pool: PgPool) -> anyhow::Result<()> {
395
        let user = USER.get().unwrap();
396
        user.commit()
397
            .await
398
            .expect("Failed to commit user to database");
399

            
400
        if let Some(CmdResult::TaggedEntities { entities, .. }) =
401
            ListAccounts::new().user_id(user.id).run().await?
402
        {
403
            assert!(
404
                entities.is_empty(),
405
                "Expected no accounts in empty database"
406
            );
407
        } else {
408
            panic!("Expected TaggedEntities result");
409
        }
410
    }
411

            
412
    #[local_db_sqlx_test]
413
    async fn test_list_accounts_with_data(pool: PgPool) -> anyhow::Result<()> {
414
        let user = USER.get().unwrap();
415
        user.commit()
416
            .await
417
            .expect("Failed to commit user to database");
418

            
419
        // First create a commodity
420
        let commodity_result = CreateCommodity::new()
421
            .fraction(1.into())
422
            .symbol("TST".to_string())
423
            .name("Test Commodity".to_string())
424
            .user_id(user.id)
425
            .run()
426
            .await?;
427

            
428
        // Get the commodity ID and create a commodity entity
429
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
430
            uuid::Uuid::parse_str(&id)?
431
        } else {
432
            panic!("Expected commodity ID string result");
433
        };
434
        let _commodity = Commodity {
435
            id: commodity_id,
436
            fraction: 1,
437
        };
438

            
439
        // Create an account
440
        CreateAccount::new()
441
            .name("Test Account".to_string())
442
            .user_id(user.id)
443
            .run()
444
            .await?;
445

            
446
        // List accounts
447
        if let Some(CmdResult::TaggedEntities { entities, .. }) =
448
            ListAccounts::new().user_id(user.id).run().await?
449
        {
450
            assert_eq!(entities.len(), 1, "Expected one account");
451

            
452
            let (entity, tags) = &entities[0];
453
            if let FinanceEntity::Account(_) = entity {
454
                // Check tags
455
                assert_eq!(tags.len(), 1); // name tag
456
                if let FinanceEntity::Tag(tag) = &tags["name"] {
457
                    assert_eq!(tag.tag_name, "name");
458
                    assert_eq!(tag.tag_value, "Test Account");
459
                } else {
460
                    panic!("Expected Tag entity");
461
                }
462
            } else {
463
                panic!("Expected Account entity");
464
            }
465
        } else {
466
            panic!("Expected TaggedEntities result");
467
        }
468
    }
469

            
470
    #[local_db_sqlx_test]
471
    async fn test_get_account(pool: PgPool) -> anyhow::Result<()> {
472
        let user = USER.get().unwrap();
473
        user.commit()
474
            .await
475
            .expect("Failed to commit user to database");
476

            
477
        // First create a commodity
478
        let commodity_result = CreateCommodity::new()
479
            .fraction(1.into())
480
            .symbol("TST".to_string())
481
            .name("Test Commodity".to_string())
482
            .user_id(user.id)
483
            .run()
484
            .await?;
485

            
486
        // Get the commodity ID
487
        let _commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
488
            uuid::Uuid::parse_str(&id)?
489
        } else {
490
            panic!("Expected commodity ID string result");
491
        };
492

            
493
        // Create an account
494
        let account_name = "Test Account";
495
        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
496
            CreateAccount::new()
497
                .name(account_name.to_string())
498
                .user_id(user.id)
499
                .run()
500
                .await?
501
        {
502
            account
503
        } else {
504
            panic!("Expected account entity result");
505
        };
506

            
507
        // Test GetAccount by ID
508
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
509
            .user_id(user.id)
510
            .account_id(account.id)
511
            .run()
512
            .await?
513
        {
514
            assert_eq!(entities.len(), 1, "Expected one account");
515

            
516
            let (entity, tags) = &entities[0];
517
            if let FinanceEntity::Account(a) = entity {
518
                assert_eq!(a.id, account.id);
519

            
520
                // Check tags
521
                assert_eq!(tags.len(), 1); // name tag
522
                if let FinanceEntity::Tag(tag) = &tags["name"] {
523
                    assert_eq!(tag.tag_name, "name");
524
                    assert_eq!(tag.tag_value, account_name);
525
                } else {
526
                    panic!("Expected Tag entity");
527
                }
528
            } else {
529
                panic!("Expected Account entity");
530
            }
531
        } else {
532
            panic!("Expected TaggedEntities result");
533
        }
534

            
535
        // Test GetAccount by name
536
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
537
            .user_id(user.id)
538
            .account_name(account_name.to_string())
539
            .run()
540
            .await?
541
        {
542
            assert_eq!(entities.len(), 1, "Expected one account");
543

            
544
            let (entity, _) = &entities[0];
545
            if let FinanceEntity::Account(a) = entity {
546
                assert_eq!(a.id, account.id);
547
            } else {
548
                panic!("Expected Account entity");
549
            }
550
        } else {
551
            panic!("Expected TaggedEntities result");
552
        }
553

            
554
        // Test with non-existent account ID
555
        let non_existent_id = Uuid::new_v4();
556
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
557
            .user_id(user.id)
558
            .account_id(non_existent_id)
559
            .run()
560
            .await?
561
        {
562
            assert_eq!(
563
                entities.len(),
564
                0,
565
                "Expected no accounts for non-existent ID"
566
            );
567
        } else {
568
            panic!("Expected empty TaggedEntities result");
569
        }
570

            
571
        // Test with non-existent account name
572
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
573
            .user_id(user.id)
574
            .account_name("Non-existent Account".to_string())
575
            .run()
576
            .await?
577
        {
578
            assert_eq!(
579
                entities.len(),
580
                0,
581
                "Expected no accounts for non-existent name"
582
            );
583
        } else {
584
            panic!("Expected empty TaggedEntities result");
585
        }
586
    }
587

            
588
    #[local_db_sqlx_test]
589
    async fn test_get_account_commodities_no_transactions(pool: PgPool) -> anyhow::Result<()> {
590
        let user = USER.get().unwrap();
591
        user.commit()
592
            .await
593
            .expect("Failed to commit user to database");
594

            
595
        // Create a commodity
596
        let _commodity_result = CreateCommodity::new()
597
            .fraction(1.into())
598
            .symbol("USD".to_string())
599
            .name("US Dollar".to_string())
600
            .user_id(user.id)
601
            .run()
602
            .await?;
603

            
604
        // Create an account with no transactions
605
        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
606
            CreateAccount::new()
607
                .name("Empty Account".to_string())
608
                .user_id(user.id)
609
                .run()
610
                .await?
611
        {
612
            account
613
        } else {
614
            panic!("Expected account entity result");
615
        };
616

            
617
        // Test GetAccountCommodities on account with no transactions
618
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
619
            .user_id(user.id)
620
            .account_id(account.id)
621
            .run()
622
            .await?
623
        {
624
            assert_eq!(
625
                commodities.len(),
626
                0,
627
                "Expected no commodities for account with no transactions"
628
            );
629
        } else {
630
            panic!("Expected CommodityInfoList result");
631
        }
632
    }
633

            
634
    #[local_db_sqlx_test]
635
    async fn test_get_account_commodities_single_commodity(pool: PgPool) -> anyhow::Result<()> {
636
        let user = USER.get().unwrap();
637
        user.commit()
638
            .await
639
            .expect("Failed to commit user to database");
640

            
641
        // Create a commodity
642
        let commodity_result = CreateCommodity::new()
643
            .fraction(1.into())
644
            .symbol("EUR".to_string())
645
            .name("Euro".to_string())
646
            .user_id(user.id)
647
            .run()
648
            .await?;
649

            
650
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
651
            uuid::Uuid::parse_str(&id)?
652
        } else {
653
            panic!("Expected commodity ID string result");
654
        };
655

            
656
        // Create two accounts
657
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
658
            CreateAccount::new()
659
                .name("Account 1".to_string())
660
                .user_id(user.id)
661
                .run()
662
                .await?
663
        {
664
            account
665
        } else {
666
            panic!("Expected account entity result");
667
        };
668

            
669
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
670
            CreateAccount::new()
671
                .name("Account 2".to_string())
672
                .user_id(user.id)
673
                .run()
674
                .await?
675
        {
676
            account
677
        } else {
678
            panic!("Expected account entity result");
679
        };
680

            
681
        // Create a transaction with single commodity (EUR)
682
        let tx_id = Uuid::new_v4();
683
        let now = Utc::now();
684

            
685
        let split1 = Split {
686
            id: Uuid::new_v4(),
687
            tx_id,
688
            account_id: account1.id,
689
            commodity_id,
690
            value_num: -500,
691
            value_denom: 1,
692
            reconcile_state: None,
693
            reconcile_date: None,
694
            lot_id: None,
695
        };
696

            
697
        let split2 = Split {
698
            id: Uuid::new_v4(),
699
            tx_id,
700
            account_id: account2.id,
701
            commodity_id,
702
            value_num: 500,
703
            value_denom: 1,
704
            reconcile_state: None,
705
            reconcile_date: None,
706
            lot_id: None,
707
        };
708

            
709
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
710
        CreateTransaction::new()
711
            .user_id(user.id)
712
            .splits(splits)
713
            .id(tx_id)
714
            .post_date(now)
715
            .enter_date(now)
716
            .run()
717
            .await?;
718

            
719
        // Test GetAccountCommodities on account1 (should have one commodity)
720
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
721
            .user_id(user.id)
722
            .account_id(account1.id)
723
            .run()
724
            .await?
725
        {
726
            assert_eq!(
727
                commodities.len(),
728
                1,
729
                "Expected one commodity for account with single currency"
730
            );
731

            
732
            let commodity_info = &commodities[0];
733
            assert_eq!(commodity_info.commodity_id, commodity_id);
734
            assert_eq!(commodity_info.symbol, "EUR");
735
            assert_eq!(commodity_info.name, "Euro");
736
        } else {
737
            panic!("Expected CommodityInfoList result");
738
        }
739

            
740
        // Test GetAccountCommodities on account2 (should also have one commodity)
741
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
742
            .user_id(user.id)
743
            .account_id(account2.id)
744
            .run()
745
            .await?
746
        {
747
            assert_eq!(
748
                commodities.len(),
749
                1,
750
                "Expected one commodity for account with single currency"
751
            );
752

            
753
            let commodity_info = &commodities[0];
754
            assert_eq!(commodity_info.commodity_id, commodity_id);
755
            assert_eq!(commodity_info.symbol, "EUR");
756
            assert_eq!(commodity_info.name, "Euro");
757
        } else {
758
            panic!("Expected CommodityInfoList result");
759
        }
760
    }
761

            
762
    #[local_db_sqlx_test]
763
    async fn test_get_account_commodities_multiple_commodities(pool: PgPool) -> anyhow::Result<()> {
764
        let user = USER.get().unwrap();
765
        user.commit()
766
            .await
767
            .expect("Failed to commit user to database");
768

            
769
        // Create two commodities
770
        let usd_result = CreateCommodity::new()
771
            .fraction(1.into())
772
            .symbol("USD".to_string())
773
            .name("US Dollar".to_string())
774
            .user_id(user.id)
775
            .run()
776
            .await?;
777

            
778
        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
779
            uuid::Uuid::parse_str(&id)?
780
        } else {
781
            panic!("Expected commodity ID string result");
782
        };
783

            
784
        let eur_result = CreateCommodity::new()
785
            .fraction(1.into())
786
            .symbol("EUR".to_string())
787
            .name("Euro".to_string())
788
            .user_id(user.id)
789
            .run()
790
            .await?;
791

            
792
        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
793
            uuid::Uuid::parse_str(&id)?
794
        } else {
795
            panic!("Expected commodity ID string result");
796
        };
797

            
798
        // Create accounts
799
        let mixed_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
800
            CreateAccount::new()
801
                .name("Mixed Currency Account".to_string())
802
                .user_id(user.id)
803
                .run()
804
                .await?
805
        {
806
            account
807
        } else {
808
            panic!("Expected account entity result");
809
        };
810

            
811
        let other_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
812
            CreateAccount::new()
813
                .name("Other Account".to_string())
814
                .user_id(user.id)
815
                .run()
816
                .await?
817
        {
818
            account
819
        } else {
820
            panic!("Expected account entity result");
821
        };
822

            
823
        // Create first transaction with USD
824
        let tx1_id = Uuid::new_v4();
825
        let now = Utc::now();
826

            
827
        let splits1 = vec![
828
            FinanceEntity::Split(Split {
829
                id: Uuid::new_v4(),
830
                tx_id: tx1_id,
831
                account_id: mixed_account.id,
832
                commodity_id: usd_id,
833
                value_num: 100,
834
                value_denom: 1,
835
                reconcile_state: None,
836
                reconcile_date: None,
837
                lot_id: None,
838
            }),
839
            FinanceEntity::Split(Split {
840
                id: Uuid::new_v4(),
841
                tx_id: tx1_id,
842
                account_id: other_account.id,
843
                commodity_id: usd_id,
844
                value_num: -100,
845
                value_denom: 1,
846
                reconcile_state: None,
847
                reconcile_date: None,
848
                lot_id: None,
849
            }),
850
        ];
851

            
852
        CreateTransaction::new()
853
            .user_id(user.id)
854
            .splits(splits1)
855
            .id(tx1_id)
856
            .post_date(now)
857
            .enter_date(now)
858
            .run()
859
            .await?;
860

            
861
        // Create second transaction with EUR
862
        let tx2_id = Uuid::new_v4();
863

            
864
        let splits2 = vec![
865
            FinanceEntity::Split(Split {
866
                id: Uuid::new_v4(),
867
                tx_id: tx2_id,
868
                account_id: mixed_account.id,
869
                commodity_id: eur_id,
870
                value_num: 200,
871
                value_denom: 1,
872
                reconcile_state: None,
873
                reconcile_date: None,
874
                lot_id: None,
875
            }),
876
            FinanceEntity::Split(Split {
877
                id: Uuid::new_v4(),
878
                tx_id: tx2_id,
879
                account_id: other_account.id,
880
                commodity_id: eur_id,
881
                value_num: -200,
882
                value_denom: 1,
883
                reconcile_state: None,
884
                reconcile_date: None,
885
                lot_id: None,
886
            }),
887
        ];
888

            
889
        CreateTransaction::new()
890
            .user_id(user.id)
891
            .splits(splits2)
892
            .id(tx2_id)
893
            .post_date(now)
894
            .enter_date(now)
895
            .run()
896
            .await?;
897

            
898
        // Test GetAccountCommodities on mixed_account (should have two commodities)
899
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
900
            .user_id(user.id)
901
            .account_id(mixed_account.id)
902
            .run()
903
            .await?
904
        {
905
            assert_eq!(
906
                commodities.len(),
907
                2,
908
                "Expected two commodities for account with mixed currencies"
909
            );
910

            
911
            // Should be sorted by symbol (EUR comes before USD alphabetically)
912
            let eur_info = &commodities[0];
913
            assert_eq!(eur_info.commodity_id, eur_id);
914
            assert_eq!(eur_info.symbol, "EUR");
915
            assert_eq!(eur_info.name, "Euro");
916

            
917
            let usd_info = &commodities[1];
918
            assert_eq!(usd_info.commodity_id, usd_id);
919
            assert_eq!(usd_info.symbol, "USD");
920
            assert_eq!(usd_info.name, "US Dollar");
921
        } else {
922
            panic!("Expected CommodityInfoList result");
923
        }
924

            
925
        // Test GetAccountCommodities on other_account (should also have two commodities)
926
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
927
            .user_id(user.id)
928
            .account_id(other_account.id)
929
            .run()
930
            .await?
931
        {
932
            assert_eq!(
933
                commodities.len(),
934
                2,
935
                "Expected two commodities for account with mixed currencies"
936
            );
937
        } else {
938
            panic!("Expected CommodityInfoList result");
939
        }
940
    }
941

            
942
    #[local_db_sqlx_test]
943
    async fn test_get_account_commodities_error_cases(pool: PgPool) -> anyhow::Result<()> {
944
        let user = USER.get().unwrap();
945
        user.commit()
946
            .await
947
            .expect("Failed to commit user to database");
948

            
949
        // Test with non-existent account ID
950
        let non_existent_account_id = Uuid::new_v4();
951
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
952
            .user_id(user.id)
953
            .account_id(non_existent_account_id)
954
            .run()
955
            .await?
956
        {
957
            assert_eq!(
958
                commodities.len(),
959
                0,
960
                "Expected no commodities for non-existent account"
961
            );
962
        } else {
963
            panic!("Expected CommodityInfoList result");
964
        }
965

            
966
        // Test with non-existent user ID
967
        let non_existent_user_id = Uuid::new_v4();
968
        let result = GetAccountCommodities::new()
969
            .user_id(non_existent_user_id)
970
            .account_id(Uuid::new_v4())
971
            .run()
972
            .await;
973

            
974
        // Should succeed but return empty list since user isolation prevents access
975
        if let Ok(Some(CmdResult::CommodityInfoList(commodities))) = result {
976
            assert_eq!(
977
                commodities.len(),
978
                0,
979
                "Expected no commodities for non-existent user"
980
            );
981
        } else {
982
            // Or it might fail due to connection issues, which is also acceptable
983
            assert!(
984
                result.is_err(),
985
                "Expected error or empty result for non-existent user"
986
            );
987
        }
988
    }
989

            
990
    #[local_db_sqlx_test]
991
    async fn test_multi_currency_account_balance_with_price_conversion(pool: PgPool) {
992
        setup().await;
993
        let user = USER.get().unwrap();
994
        user.commit()
995
            .await
996
            .expect("Failed to commit user to database");
997

            
998
        // Step 1: Create two commodities (USD and EUR)
999
        let usd_result = CreateCommodity::new()
            .fraction(1.into())
            .symbol("USD".to_string())
            .name("US Dollar".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let eur_result = CreateCommodity::new()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .symbol("USD".to_string())
            .name("US Dollar".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let eur_result = CreateCommodity::new()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .symbol("USD".to_string())
            .name("US Dollar".to_string())
            .user_id(user.id)
            .run()
            .await
            .expect("Test operation failed");
        let eur_result = CreateCommodity::new()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .symbol("USD".to_string())
            .name("US Dollar".to_string())
            .user_id(user.id)
            .run()
            .await
            .unwrap();
        let eur_result = CreateCommodity::new()
            .fraction(1.into())
            .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");
        }
    }
}