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
388
}
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
    ListAccountsForManage {
89
        #[required]
90
        user_id: Uuid,
91
    } => {
92
        let user = User { id: user_id };
93
1
        let mut conn = user.get_connection().await.map_err(|err| {
94
1
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
95
1
            ConfigError::DB
96
1
        })?;
97

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
240
        Ok(Some(CmdResult::String("ok".to_string())))
241
    }
242
31
}
243

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

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

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

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

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

            
288
        // If account found, get its tags
289
        if let Some(row) = account_query {
290
            let account = Account::builder()
291
                .id(row.id)
292
                .build()
293
                .expect("Account built with all required fields");
294

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

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

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

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

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

            
357
        Ok(Some(CmdResult::CommodityInfoList(commodity_infos)))
358
    }
359
28
}
360

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

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

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

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

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

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

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

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

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

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

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

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

            
469
                    Ok(Some(CmdResult::MultiCurrencyBalance(result)))
470
                }
471
            }
472
        }
473
    }
474
80
}
475

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
742
        // Create a commodity
743
        let _commodity_result = CreateCommodity::new()
744
            .symbol("USD".to_string())
745
            .name("US Dollar".to_string())
746
            .user_id(user.id)
747
            .run()
748
            .await?;
749

            
750
        // Create an account with no transactions
751
        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
752
            CreateAccount::new()
753
                .name("Empty Account".to_string())
754
                .user_id(user.id)
755
                .run()
756
                .await?
757
        {
758
            account
759
        } else {
760
            panic!("Expected account entity result");
761
        };
762

            
763
        // Test GetAccountCommodities on account with no transactions
764
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
765
            .user_id(user.id)
766
            .account_id(account.id)
767
            .run()
768
            .await?
769
        {
770
            assert_eq!(
771
                commodities.len(),
772
                0,
773
                "Expected no commodities for account with no transactions"
774
            );
775
        } else {
776
            panic!("Expected CommodityInfoList result");
777
        }
778
    }
779

            
780
    #[local_db_sqlx_test]
781
    async fn test_get_account_commodities_single_commodity(pool: PgPool) -> anyhow::Result<()> {
782
        let user = USER.get().unwrap();
783
        user.commit()
784
            .await
785
            .expect("Failed to commit user to database");
786

            
787
        // Create a commodity
788
        let commodity_result = CreateCommodity::new()
789
            .symbol("EUR".to_string())
790
            .name("Euro".to_string())
791
            .user_id(user.id)
792
            .run()
793
            .await?;
794

            
795
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
796
            uuid::Uuid::parse_str(&id)?
797
        } else {
798
            panic!("Expected commodity ID string result");
799
        };
800

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

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

            
826
        // Create a transaction with single commodity (EUR)
827
        let tx_id = Uuid::new_v4();
828
        let now = Utc::now();
829

            
830
        let split1 = Split {
831
            id: Uuid::new_v4(),
832
            tx_id,
833
            account_id: account1.id,
834
            commodity_id,
835
            value_num: -500,
836
            value_denom: 1,
837
            reconcile_state: None,
838
            reconcile_date: None,
839
            lot_id: None,
840
        };
841

            
842
        let split2 = Split {
843
            id: Uuid::new_v4(),
844
            tx_id,
845
            account_id: account2.id,
846
            commodity_id,
847
            value_num: 500,
848
            value_denom: 1,
849
            reconcile_state: None,
850
            reconcile_date: None,
851
            lot_id: None,
852
        };
853

            
854
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
855
        CreateTransaction::new()
856
            .user_id(user.id)
857
            .splits(splits)
858
            .id(tx_id)
859
            .post_date(now)
860
            .enter_date(now)
861
            .run()
862
            .await?;
863

            
864
        // Test GetAccountCommodities on account1 (should have one commodity)
865
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
866
            .user_id(user.id)
867
            .account_id(account1.id)
868
            .run()
869
            .await?
870
        {
871
            assert_eq!(
872
                commodities.len(),
873
                1,
874
                "Expected one commodity for account with single currency"
875
            );
876

            
877
            let commodity_info = &commodities[0];
878
            assert_eq!(commodity_info.commodity_id, commodity_id);
879
            assert_eq!(commodity_info.symbol, "EUR");
880
            assert_eq!(commodity_info.name, "Euro");
881
        } else {
882
            panic!("Expected CommodityInfoList result");
883
        }
884

            
885
        // Test GetAccountCommodities on account2 (should also have one commodity)
886
        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
887
            .user_id(user.id)
888
            .account_id(account2.id)
889
            .run()
890
            .await?
891
        {
892
            assert_eq!(
893
                commodities.len(),
894
                1,
895
                "Expected one commodity for account with single currency"
896
            );
897

            
898
            let commodity_info = &commodities[0];
899
            assert_eq!(commodity_info.commodity_id, commodity_id);
900
            assert_eq!(commodity_info.symbol, "EUR");
901
            assert_eq!(commodity_info.name, "Euro");
902
        } else {
903
            panic!("Expected CommodityInfoList result");
904
        }
905
    }
906

            
907
    #[local_db_sqlx_test]
908
    async fn test_get_account_commodities_multiple_commodities(pool: PgPool) -> anyhow::Result<()> {
909
        let user = USER.get().unwrap();
910
        user.commit()
911
            .await
912
            .expect("Failed to commit user to database");
913

            
914
        // Create two commodities
915
        let usd_result = CreateCommodity::new()
916
            .symbol("USD".to_string())
917
            .name("US Dollar".to_string())
918
            .user_id(user.id)
919
            .run()
920
            .await?;
921

            
922
        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
923
            uuid::Uuid::parse_str(&id)?
924
        } else {
925
            panic!("Expected commodity ID string result");
926
        };
927

            
928
        let eur_result = CreateCommodity::new()
929
            .symbol("EUR".to_string())
930
            .name("Euro".to_string())
931
            .user_id(user.id)
932
            .run()
933
            .await?;
934

            
935
        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
936
            uuid::Uuid::parse_str(&id)?
937
        } else {
938
            panic!("Expected commodity ID string result");
939
        };
940

            
941
        // Create accounts
942
        let mixed_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
943
            CreateAccount::new()
944
                .name("Mixed Currency Account".to_string())
945
                .user_id(user.id)
946
                .run()
947
                .await?
948
        {
949
            account
950
        } else {
951
            panic!("Expected account entity result");
952
        };
953

            
954
        let other_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
955
            CreateAccount::new()
956
                .name("Other Account".to_string())
957
                .user_id(user.id)
958
                .run()
959
                .await?
960
        {
961
            account
962
        } else {
963
            panic!("Expected account entity result");
964
        };
965

            
966
        // Create first transaction with USD
967
        let tx1_id = Uuid::new_v4();
968
        let now = Utc::now();
969

            
970
        let splits1 = vec![
971
            FinanceEntity::Split(Split {
972
                id: Uuid::new_v4(),
973
                tx_id: tx1_id,
974
                account_id: mixed_account.id,
975
                commodity_id: usd_id,
976
                value_num: 100,
977
                value_denom: 1,
978
                reconcile_state: None,
979
                reconcile_date: None,
980
                lot_id: None,
981
            }),
982
            FinanceEntity::Split(Split {
983
                id: Uuid::new_v4(),
984
                tx_id: tx1_id,
985
                account_id: other_account.id,
986
                commodity_id: usd_id,
987
                value_num: -100,
988
                value_denom: 1,
989
                reconcile_state: None,
990
                reconcile_date: None,
991
                lot_id: None,
992
            }),
993
        ];
994

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