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

            
33
command! {
34
    ListAccounts {
35
        #[required]
36
        user_id: Uuid,
37
    } => {
38
        let user = User { id: user_id };
39
22
        let mut conn = user.get_connection().await.map_err(|err| {
40
22
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
41
22
            ConfigError::DB
42
22
        })?;
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
2
                    .map(|row| {
64
2
                        (
65
2
                            row.tag_name.clone(),
66
2
                            FinanceEntity::Tag(Tag {
67
2
                                id: row.id,
68
2
                                tag_name: row.tag_name,
69
2
                                tag_value: row.tag_value,
70
2
                                description: row.description,
71
2
                            }),
72
2
                        )
73
2
                    })
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(tagged_accounts)))
81
    }
82
166
}
83

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

            
99
        // Get account by ID or name
100
        let mut account_id_option: Option<Uuid> = None;
101

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

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

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

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

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

            
154
            let tagged_account = vec![(FinanceEntity::Account(account), tags)];
155
            Ok(Some(CmdResult::TaggedEntities(tagged_account)))
156
        } else {
157
            // No account found
158
            Ok(Some(CmdResult::TaggedEntities(vec![])))
159
        }
160
    }
161
32
}
162

            
163
command! {
164
    GetAccountCommodities {
165
        #[required]
166
        user_id: Uuid,
167
        #[required]
168
        account_id: Uuid,
169
    } => {
170
        let user = User { id: user_id };
171
        let mut conn = user.get_connection().await.map_err(|err| {
172
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
173
            ConfigError::DB
174
        })?;
175

            
176
        // Get unique commodities for this account
177
        let rows = sqlx::query_file!("sql/select/accounts/commodities.sql", &account_id)
178
            .fetch_all(&mut *conn)
179
            .await?;
180

            
181
        // Return the commodity information as structured data
182
        let mut commodity_infos = Vec::new();
183
        for row in rows {
184
            commodity_infos.push(CommodityInfo {
185
                commodity_id: row.commodity_id,
186
                symbol: row.symbol,
187
                name: row.commodity_name,
188
            });
189
        }
190

            
191
        Ok(Some(CmdResult::CommodityInfoList(commodity_infos)))
192
    }
193
56
}
194

            
195
command! {
196
    GetBalance {
197
        #[required]
198
        user_id: Uuid,
199
        #[required]
200
        account_id: Uuid,
201
        #[optional]
202
        commodity_id: Uuid,
203
    } => {
204
        let user = User { id: user_id };
205
        let mut conn = user.get_connection().await.map_err(|err| {
206
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
207
            ConfigError::DB
208
        })?;
209

            
210
        // Get all splits with their conversion information
211
        let splits_data = sqlx::query_file!(
212
            "sql/balance/accounts/splits/all_with_conversion.sql",
213
            &account_id,
214
            commodity_id.as_ref()
215
        )
216
        .fetch_all(&mut *conn)
217
        .await?;
218

            
219
        if splits_data.is_empty() {
220
            // No splits - return zero balance as rational regardless of currency request
221
            return Ok(Some(CmdResult::Rational(Rational64::new(0, 1))));
222
        }
223

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

            
227
        match commodity_id {
228
            Some(target_commodity_id) => {
229
                // Single currency mode - convert everything to target commodity
230
                let mut total_balance = Rational64::new(0, 1);
231

            
232
                for split_data in splits_data {
233
                    let split_value = Rational64::new(split_data.value_num, split_data.value_denom);
234

            
235
                    if split_data.commodity_id == target_commodity_id {
236
                        // Same commodity, add directly
237
                        total_balance += split_value;
238
                    } else {
239
                        // Different commodity, need conversion
240
                        match (split_data.price_num, split_data.price_denom) {
241
                            (Some(price_num), Some(price_denom)) => {
242
                                // Conversion data available
243
                                let price_ratio = Rational64::new(price_num, price_denom);
244
                                let converted_value = split_value * price_ratio;
245
                                total_balance += converted_value;
246
                            }
247
                            _ => {
248
                                // Missing conversion data - get target commodity symbol for error
249
                                let to_symbol = sqlx::query_file_scalar!(
250
                                    "sql/select/commodities/symbol.sql",
251
                                    &target_commodity_id
252
                                )
253
                                .fetch_optional(&mut *conn)
254
                                .await?
255
                                .unwrap_or_else(|| target_commodity_id.to_string());
256

            
257
                                return Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
258
                                    split_id: split_data.split_id,
259
                                    from_commodity: split_data.commodity_symbol.clone(),
260
                                    to_commodity: to_symbol,
261
                                })));
262
                            }
263
                        }
264
                    }
265
                }
266

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

            
282
                    for split_data in splits_data {
283
                        let split_value = Rational64::new(split_data.value_num, split_data.value_denom);
284

            
285
                        balances_by_commodity
286
                            .entry(split_data.commodity_id)
287
                            .and_modify(|(_, balance, _)| *balance += split_value)
288
20
                            .or_insert_with(|| {
289
20
                                let commodity = Commodity {
290
20
                                    id: split_data.commodity_id,
291
20
                                    fraction: split_data.commodity_fraction,
292
20
                                };
293
20
                                (commodity, split_value, split_data.commodity_symbol.clone())
294
20
                            });
295
                    }
296

            
297
                    // Convert to sorted vector (sort by symbol)
298
                    let mut result: Vec<(Commodity, Rational64)> = balances_by_commodity
299
                        .into_values()
300
20
                        .map(|(commodity, balance, _symbol)| (commodity, balance))
301
                        .collect();
302
10
                    result.sort_by(|a, b| {
303
                        // Sort by commodity_id since we don't have symbol in Commodity struct
304
10
                        a.0.id.cmp(&b.0.id)
305
10
                    });
306

            
307
                    Ok(Some(CmdResult::MultiCurrencyBalance(result)))
308
                }
309
            }
310
        }
311
    }
312
160
}
313

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

            
329
    /// Context for keeping environment intact
330
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
331
    static USER: OnceCell<User> = OnceCell::const_new();
332

            
333
60
    async fn setup() {
334
40
        CONTEXT
335
40
            .get_or_init(|| async {
336
                #[cfg(feature = "testlog")]
337
2
                let _ = env_logger::builder()
338
2
                    .is_test(true)
339
2
                    .filter_level(log::LevelFilter::Trace)
340
2
                    .try_init();
341
4
            })
342
40
            .await;
343
40
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
344
40
            .await;
345
40
    }
346

            
347
    #[local_db_sqlx_test]
348
    async fn test_create_account(pool: PgPool) -> anyhow::Result<()> {
349
        let user = USER.get().unwrap();
350
        user.commit()
351
            .await
352
            .expect("Failed to commit user to database");
353

            
354
        // First create a commodity
355
        let commodity_result = CreateCommodity::new()
356
            .fraction(1.into())
357
            .symbol("TST".to_string())
358
            .name("Test Commodity".to_string())
359
            .user_id(user.id)
360
            .run()
361
            .await?;
362

            
363
        // Get the commodity ID and create a commodity entity
364
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
365
            uuid::Uuid::parse_str(&id)?
366
        } else {
367
            panic!("Expected commodity ID string result");
368
        };
369
        let commodity = Commodity {
370
            id: commodity_id,
371
            fraction: 1,
372
        };
373

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

            
387
    #[local_db_sqlx_test]
388
    async fn test_list_accounts_empty(pool: PgPool) -> anyhow::Result<()> {
389
        let user = USER.get().unwrap();
390
        user.commit()
391
            .await
392
            .expect("Failed to commit user to database");
393

            
394
        if let Some(CmdResult::TaggedEntities(entities)) =
395
            ListAccounts::new().user_id(user.id).run().await?
396
        {
397
            assert!(
398
                entities.is_empty(),
399
                "Expected no accounts in empty database"
400
            );
401
        } else {
402
            panic!("Expected TaggedEntities result");
403
        }
404
    }
405

            
406
    #[local_db_sqlx_test]
407
    async fn test_list_accounts_with_data(pool: PgPool) -> anyhow::Result<()> {
408
        let user = USER.get().unwrap();
409
        user.commit()
410
            .await
411
            .expect("Failed to commit user to database");
412

            
413
        // First create a commodity
414
        let commodity_result = CreateCommodity::new()
415
            .fraction(1.into())
416
            .symbol("TST".to_string())
417
            .name("Test Commodity".to_string())
418
            .user_id(user.id)
419
            .run()
420
            .await?;
421

            
422
        // Get the commodity ID and create a commodity entity
423
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
424
            uuid::Uuid::parse_str(&id)?
425
        } else {
426
            panic!("Expected commodity ID string result");
427
        };
428
        let commodity = Commodity {
429
            id: commodity_id,
430
            fraction: 1,
431
        };
432

            
433
        // Create an account
434
        CreateAccount::new()
435
            .name("Test Account".to_string())
436
            .user_id(user.id)
437
            .run()
438
            .await?;
439

            
440
        // List accounts
441
        if let Some(CmdResult::TaggedEntities(entities)) =
442
            ListAccounts::new().user_id(user.id).run().await?
443
        {
444
            assert_eq!(entities.len(), 1, "Expected one account");
445

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

            
464
    #[local_db_sqlx_test]
465
    async fn test_get_account(pool: PgPool) -> anyhow::Result<()> {
466
        let user = USER.get().unwrap();
467
        user.commit()
468
            .await
469
            .expect("Failed to commit user to database");
470

            
471
        // First create a commodity
472
        let commodity_result = CreateCommodity::new()
473
            .fraction(1.into())
474
            .symbol("TST".to_string())
475
            .name("Test Commodity".to_string())
476
            .user_id(user.id)
477
            .run()
478
            .await?;
479

            
480
        // Get the commodity ID
481
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
482
            uuid::Uuid::parse_str(&id)?
483
        } else {
484
            panic!("Expected commodity ID string result");
485
        };
486

            
487
        // Create an account
488
        let account_name = "Test Account";
489
        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
490
            CreateAccount::new()
491
                .name(account_name.to_string())
492
                .user_id(user.id)
493
                .run()
494
                .await?
495
        {
496
            account
497
        } else {
498
            panic!("Expected account entity result");
499
        };
500

            
501
        // Test GetAccount by ID
502
        if let Some(CmdResult::TaggedEntities(entities)) = GetAccount::new()
503
            .user_id(user.id)
504
            .account_id(account.id)
505
            .run()
506
            .await?
507
        {
508
            assert_eq!(entities.len(), 1, "Expected one account");
509

            
510
            let (entity, tags) = &entities[0];
511
            if let FinanceEntity::Account(a) = entity {
512
                assert_eq!(a.id, account.id);
513

            
514
                // Check tags
515
                assert_eq!(tags.len(), 1); // name tag
516
                if let FinanceEntity::Tag(tag) = &tags["name"] {
517
                    assert_eq!(tag.tag_name, "name");
518
                    assert_eq!(tag.tag_value, account_name);
519
                } else {
520
                    panic!("Expected Tag entity");
521
                }
522
            } else {
523
                panic!("Expected Account entity");
524
            }
525
        } else {
526
            panic!("Expected TaggedEntities result");
527
        }
528

            
529
        // Test GetAccount by name
530
        if let Some(CmdResult::TaggedEntities(entities)) = GetAccount::new()
531
            .user_id(user.id)
532
            .account_name(account_name.to_string())
533
            .run()
534
            .await?
535
        {
536
            assert_eq!(entities.len(), 1, "Expected one account");
537

            
538
            let (entity, _) = &entities[0];
539
            if let FinanceEntity::Account(a) = entity {
540
                assert_eq!(a.id, account.id);
541
            } else {
542
                panic!("Expected Account entity");
543
            }
544
        } else {
545
            panic!("Expected TaggedEntities result");
546
        }
547

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

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

            
582
    #[local_db_sqlx_test]
583
    async fn test_get_account_commodities_no_transactions(pool: PgPool) -> anyhow::Result<()> {
584
        let user = USER.get().unwrap();
585
        user.commit()
586
            .await
587
            .expect("Failed to commit user to database");
588

            
589
        // Create a commodity
590
        let commodity_result = CreateCommodity::new()
591
            .fraction(1.into())
592
            .symbol("USD".to_string())
593
            .name("US Dollar".to_string())
594
            .user_id(user.id)
595
            .run()
596
            .await?;
597

            
598
        // Create an account with no transactions
599
        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
600
            CreateAccount::new()
601
                .name("Empty Account".to_string())
602
                .user_id(user.id)
603
                .run()
604
                .await?
605
        {
606
            account
607
        } else {
608
            panic!("Expected account entity result");
609
        };
610

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

            
628
    #[local_db_sqlx_test]
629
    async fn test_get_account_commodities_single_commodity(pool: PgPool) -> anyhow::Result<()> {
630
        let user = USER.get().unwrap();
631
        user.commit()
632
            .await
633
            .expect("Failed to commit user to database");
634

            
635
        // Create a commodity
636
        let commodity_result = CreateCommodity::new()
637
            .fraction(1.into())
638
            .symbol("EUR".to_string())
639
            .name("Euro".to_string())
640
            .user_id(user.id)
641
            .run()
642
            .await?;
643

            
644
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
645
            uuid::Uuid::parse_str(&id)?
646
        } else {
647
            panic!("Expected commodity ID string result");
648
        };
649

            
650
        // Create two accounts
651
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
652
            CreateAccount::new()
653
                .name("Account 1".to_string())
654
                .user_id(user.id)
655
                .run()
656
                .await?
657
        {
658
            account
659
        } else {
660
            panic!("Expected account entity result");
661
        };
662

            
663
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
664
            CreateAccount::new()
665
                .name("Account 2".to_string())
666
                .user_id(user.id)
667
                .run()
668
                .await?
669
        {
670
            account
671
        } else {
672
            panic!("Expected account entity result");
673
        };
674

            
675
        // Create a transaction with single commodity (EUR)
676
        let tx_id = Uuid::new_v4();
677
        let now = Utc::now();
678

            
679
        let split1 = Split {
680
            id: Uuid::new_v4(),
681
            tx_id,
682
            account_id: account1.id,
683
            commodity_id,
684
            value_num: -500,
685
            value_denom: 1,
686
            reconcile_state: None,
687
            reconcile_date: None,
688
            lot_id: None,
689
        };
690

            
691
        let split2 = Split {
692
            id: Uuid::new_v4(),
693
            tx_id,
694
            account_id: account2.id,
695
            commodity_id,
696
            value_num: 500,
697
            value_denom: 1,
698
            reconcile_state: None,
699
            reconcile_date: None,
700
            lot_id: None,
701
        };
702

            
703
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
704
        CreateTransaction::new()
705
            .user_id(user.id)
706
            .splits(splits)
707
            .id(tx_id)
708
            .post_date(now)
709
            .enter_date(now)
710
            .run()
711
            .await?;
712

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

            
726
            let commodity_info = &commodities[0];
727
            assert_eq!(commodity_info.commodity_id, commodity_id);
728
            assert_eq!(commodity_info.symbol, "EUR");
729
            assert_eq!(commodity_info.name, "Euro");
730
        } else {
731
            panic!("Expected CommodityInfoList result");
732
        }
733

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

            
747
            let commodity_info = &commodities[0];
748
            assert_eq!(commodity_info.commodity_id, commodity_id);
749
            assert_eq!(commodity_info.symbol, "EUR");
750
            assert_eq!(commodity_info.name, "Euro");
751
        } else {
752
            panic!("Expected CommodityInfoList result");
753
        }
754
    }
755

            
756
    #[local_db_sqlx_test]
757
    async fn test_get_account_commodities_multiple_commodities(pool: PgPool) -> anyhow::Result<()> {
758
        let user = USER.get().unwrap();
759
        user.commit()
760
            .await
761
            .expect("Failed to commit user to database");
762

            
763
        // Create two commodities
764
        let usd_result = CreateCommodity::new()
765
            .fraction(1.into())
766
            .symbol("USD".to_string())
767
            .name("US Dollar".to_string())
768
            .user_id(user.id)
769
            .run()
770
            .await?;
771

            
772
        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
773
            uuid::Uuid::parse_str(&id)?
774
        } else {
775
            panic!("Expected commodity ID string result");
776
        };
777

            
778
        let eur_result = CreateCommodity::new()
779
            .fraction(1.into())
780
            .symbol("EUR".to_string())
781
            .name("Euro".to_string())
782
            .user_id(user.id)
783
            .run()
784
            .await?;
785

            
786
        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
787
            uuid::Uuid::parse_str(&id)?
788
        } else {
789
            panic!("Expected commodity ID string result");
790
        };
791

            
792
        // Create accounts
793
        let mixed_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
794
            CreateAccount::new()
795
                .name("Mixed Currency Account".to_string())
796
                .user_id(user.id)
797
                .run()
798
                .await?
799
        {
800
            account
801
        } else {
802
            panic!("Expected account entity result");
803
        };
804

            
805
        let other_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
806
            CreateAccount::new()
807
                .name("Other Account".to_string())
808
                .user_id(user.id)
809
                .run()
810
                .await?
811
        {
812
            account
813
        } else {
814
            panic!("Expected account entity result");
815
        };
816

            
817
        // Create first transaction with USD
818
        let tx1_id = Uuid::new_v4();
819
        let now = Utc::now();
820

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

            
846
        CreateTransaction::new()
847
            .user_id(user.id)
848
            .splits(splits1)
849
            .id(tx1_id)
850
            .post_date(now)
851
            .enter_date(now)
852
            .run()
853
            .await?;
854

            
855
        // Create second transaction with EUR
856
        let tx2_id = Uuid::new_v4();
857

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

            
883
        CreateTransaction::new()
884
            .user_id(user.id)
885
            .splits(splits2)
886
            .id(tx2_id)
887
            .post_date(now)
888
            .enter_date(now)
889
            .run()
890
            .await?;
891

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

            
905
            // Should be sorted by symbol (EUR comes before USD alphabetically)
906
            let eur_info = &commodities[0];
907
            assert_eq!(eur_info.commodity_id, eur_id);
908
            assert_eq!(eur_info.symbol, "EUR");
909
            assert_eq!(eur_info.name, "Euro");
910

            
911
            let usd_info = &commodities[1];
912
            assert_eq!(usd_info.commodity_id, usd_id);
913
            assert_eq!(usd_info.symbol, "USD");
914
            assert_eq!(usd_info.name, "US Dollar");
915
        } else {
916
            panic!("Expected CommodityInfoList result");
917
        }
918

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

            
936
    #[local_db_sqlx_test]
937
    async fn test_get_account_commodities_error_cases(pool: PgPool) -> anyhow::Result<()> {
938
        let user = USER.get().unwrap();
939
        user.commit()
940
            .await
941
            .expect("Failed to commit user to database");
942

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

            
960
        // Test with non-existent user ID
961
        let non_existent_user_id = Uuid::new_v4();
962
        let result = GetAccountCommodities::new()
963
            .user_id(non_existent_user_id)
964
            .account_id(Uuid::new_v4())
965
            .run()
966
            .await;
967

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

            
984
    #[local_db_sqlx_test]
985
    async fn test_multi_currency_account_balance_with_price_conversion(pool: PgPool) {
986
        setup().await;
987
        let user = USER.get().unwrap();
988
        user.commit()
989
            .await
990
            .expect("Failed to commit user to database");
991

            
992
        // Step 1: Create two commodities (USD and EUR)
993
        let usd_result = CreateCommodity::new()
994
            .fraction(1.into())
995
            .symbol("USD".to_string())
996
            .name("US Dollar".to_string())
997
            .user_id(user.id)
998
            .run()
999
            .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");
        }
    }
}