Skip to main content

server/command/
account.rs

1use finance::{account::Account, commodity::Commodity, error::FinanceError, tag::Tag};
2use num_rational::Rational64;
3use sqlx::types::Uuid;
4use std::{collections::HashMap, fmt::Debug};
5use supp_macro::command;
6
7use crate::{command::CommodityInfo, config::ConfigError, user::User};
8use finance::error::BalanceError;
9
10use super::{CmdError, CmdResult, FinanceEntity};
11
12command! {
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}
32
33command! {
34    ListAccounts {
35        #[required]
36        user_id: Uuid,
37    } => {
38        let user = User { id: user_id };
39        let mut conn = user.get_connection().await.map_err(|err| {
40            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
41            ConfigError::DB
42        })?;
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                    .map(|row| {
64                        (
65                            row.tag_name.clone(),
66                            FinanceEntity::Tag(Tag {
67                                id: row.id,
68                                tag_name: row.tag_name,
69                                tag_value: row.tag_value,
70                                description: row.description,
71                            }),
72                        )
73                    })
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}
86
87command! {
88    ListAccountsForManage {
89        #[required]
90        user_id: Uuid,
91    } => {
92        let user = User { id: user_id };
93        let mut conn = user.get_connection().await.map_err(|err| {
94            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
95            ConfigError::DB
96        })?;
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}
136
137command! {
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
191command! {
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        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        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}
243
244command! {
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                    .map(|row| {
302                        (
303                            row.tag_name.clone(),
304                            FinanceEntity::Tag(Tag {
305                                id: row.id,
306                                tag_name: row.tag_name,
307                                tag_value: row.tag_value,
308                                description: row.description,
309                            }),
310                        )
311                    })
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}
328
329command! {
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}
360
361command! {
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                        .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                            .or_insert_with(|| {
452                                let commodity = Commodity {
453                                    id: split_data.commodity_id,
454                                };
455                                (commodity, split_value, split_data.commodity_symbol.clone())
456                            });
457                    }
458
459                    // Convert to sorted vector (sort by symbol)
460                    let mut result: Vec<(Commodity, Rational64)> = balances_by_commodity
461                        .into_values()
462                        .map(|(commodity, balance, _symbol)| (commodity, balance))
463                        .collect();
464                    result.sort_by(|a, b| {
465                        // Sort by commodity_id since we don't have symbol in Commodity struct
466                        a.0.id.cmp(&b.0.id)
467                    });
468
469                    Ok(Some(CmdResult::MultiCurrencyBalance(result)))
470                }
471            }
472        }
473    }
474}
475
476#[cfg(test)]
477mod 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    async fn setup() {
496        CONTEXT
497            .get_or_init(|| async {
498                #[cfg(feature = "testlog")]
499                let _ = env_logger::builder()
500                    .is_test(true)
501                    .filter_level(log::LevelFilter::Trace)
502                    .try_init();
503            })
504            .await;
505        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
506            .await;
507    }
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)
1000            .enter_date(now)
1001            .run()
1002            .await?;
1003
1004        // Create second transaction with EUR
1005        let tx2_id = Uuid::new_v4();
1006
1007        let splits2 = vec![
1008            FinanceEntity::Split(Split {
1009                id: Uuid::new_v4(),
1010                tx_id: tx2_id,
1011                account_id: mixed_account.id,
1012                commodity_id: eur_id,
1013                value_num: 200,
1014                value_denom: 1,
1015                reconcile_state: None,
1016                reconcile_date: None,
1017                lot_id: None,
1018            }),
1019            FinanceEntity::Split(Split {
1020                id: Uuid::new_v4(),
1021                tx_id: tx2_id,
1022                account_id: other_account.id,
1023                commodity_id: eur_id,
1024                value_num: -200,
1025                value_denom: 1,
1026                reconcile_state: None,
1027                reconcile_date: None,
1028                lot_id: None,
1029            }),
1030        ];
1031
1032        CreateTransaction::new()
1033            .user_id(user.id)
1034            .splits(splits2)
1035            .id(tx2_id)
1036            .post_date(now)
1037            .enter_date(now)
1038            .run()
1039            .await?;
1040
1041        // Test GetAccountCommodities on mixed_account (should have two commodities)
1042        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
1043            .user_id(user.id)
1044            .account_id(mixed_account.id)
1045            .run()
1046            .await?
1047        {
1048            assert_eq!(
1049                commodities.len(),
1050                2,
1051                "Expected two commodities for account with mixed currencies"
1052            );
1053
1054            // Should be sorted by symbol (EUR comes before USD alphabetically)
1055            let eur_info = &commodities[0];
1056            assert_eq!(eur_info.commodity_id, eur_id);
1057            assert_eq!(eur_info.symbol, "EUR");
1058            assert_eq!(eur_info.name, "Euro");
1059
1060            let usd_info = &commodities[1];
1061            assert_eq!(usd_info.commodity_id, usd_id);
1062            assert_eq!(usd_info.symbol, "USD");
1063            assert_eq!(usd_info.name, "US Dollar");
1064        } else {
1065            panic!("Expected CommodityInfoList result");
1066        }
1067
1068        // Test GetAccountCommodities on other_account (should also have two commodities)
1069        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
1070            .user_id(user.id)
1071            .account_id(other_account.id)
1072            .run()
1073            .await?
1074        {
1075            assert_eq!(
1076                commodities.len(),
1077                2,
1078                "Expected two commodities for account with mixed currencies"
1079            );
1080        } else {
1081            panic!("Expected CommodityInfoList result");
1082        }
1083    }
1084
1085    #[local_db_sqlx_test]
1086    async fn test_get_account_commodities_error_cases(pool: PgPool) -> anyhow::Result<()> {
1087        let user = USER.get().unwrap();
1088        user.commit()
1089            .await
1090            .expect("Failed to commit user to database");
1091
1092        // Test with non-existent account ID
1093        let non_existent_account_id = Uuid::new_v4();
1094        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
1095            .user_id(user.id)
1096            .account_id(non_existent_account_id)
1097            .run()
1098            .await?
1099        {
1100            assert_eq!(
1101                commodities.len(),
1102                0,
1103                "Expected no commodities for non-existent account"
1104            );
1105        } else {
1106            panic!("Expected CommodityInfoList result");
1107        }
1108
1109        // Test with non-existent user ID
1110        let non_existent_user_id = Uuid::new_v4();
1111        let result = GetAccountCommodities::new()
1112            .user_id(non_existent_user_id)
1113            .account_id(Uuid::new_v4())
1114            .run()
1115            .await;
1116
1117        // Should succeed but return empty list since user isolation prevents access
1118        if let Ok(Some(CmdResult::CommodityInfoList(commodities))) = result {
1119            assert_eq!(
1120                commodities.len(),
1121                0,
1122                "Expected no commodities for non-existent user"
1123            );
1124        } else {
1125            // Or it might fail due to connection issues, which is also acceptable
1126            assert!(
1127                result.is_err(),
1128                "Expected error or empty result for non-existent user"
1129            );
1130        }
1131    }
1132
1133    #[local_db_sqlx_test]
1134    async fn test_multi_currency_account_balance_with_price_conversion(pool: PgPool) {
1135        setup().await;
1136        let user = USER.get().unwrap();
1137        user.commit()
1138            .await
1139            .expect("Failed to commit user to database");
1140
1141        // Step 1: Create two commodities (USD and EUR)
1142        let usd_result = CreateCommodity::new()
1143            .symbol("USD".to_string())
1144            .name("US Dollar".to_string())
1145            .user_id(user.id)
1146            .run()
1147            .await
1148            .unwrap();
1149
1150        let eur_result = CreateCommodity::new()
1151            .symbol("EUR".to_string())
1152            .name("Euro".to_string())
1153            .user_id(user.id)
1154            .run()
1155            .await
1156            .unwrap();
1157
1158        // Extract commodity IDs
1159        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
1160            Uuid::parse_str(&id).unwrap()
1161        } else {
1162            panic!("Expected USD commodity ID");
1163        };
1164
1165        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
1166            Uuid::parse_str(&id).unwrap()
1167        } else {
1168            panic!("Expected EUR commodity ID");
1169        };
1170
1171        // Step 2: Create two accounts
1172        let account1_result = CreateAccount::new()
1173            .name("USD Account".to_string())
1174            .user_id(user.id)
1175            .run()
1176            .await
1177            .unwrap();
1178
1179        let account2_result = CreateAccount::new()
1180            .name("EUR Account".to_string())
1181            .user_id(user.id)
1182            .run()
1183            .await
1184            .unwrap();
1185
1186        // Extract account IDs
1187        let account1_id =
1188            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account1_result {
1189                acc.id
1190            } else {
1191                panic!("Expected USD account");
1192            };
1193
1194        let account2_id =
1195            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account2_result {
1196                acc.id
1197            } else {
1198                panic!("Expected EUR account");
1199            };
1200
1201        // Step 3: Create single-currency transactions first to test basic functionality
1202        let tx1_id = Uuid::new_v4();
1203        let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap(); // Fixed timestamp
1204
1205        let tx1_split1 = Split {
1206            id: Uuid::new_v4(),
1207            tx_id: tx1_id,
1208            account_id: account1_id,
1209            commodity_id: usd_id,
1210            value_num: -100,
1211            value_denom: 1,
1212            reconcile_state: None,
1213            reconcile_date: None,
1214            lot_id: None,
1215        };
1216
1217        let tx1_split2 = Split {
1218            id: Uuid::new_v4(),
1219            tx_id: tx1_id,
1220            account_id: account2_id,
1221            commodity_id: usd_id, // Same currency to make transaction balance
1222            value_num: 100,
1223            value_denom: 1,
1224            reconcile_state: None,
1225            reconcile_date: None,
1226            lot_id: None,
1227        };
1228
1229        let tx1_splits = vec![
1230            FinanceEntity::Split(tx1_split1),
1231            FinanceEntity::Split(tx1_split2),
1232        ];
1233
1234        CreateTransaction::new()
1235            .user_id(user.id)
1236            .splits(tx1_splits)
1237            .id(tx1_id)
1238            .post_date(now)
1239            .enter_date(now)
1240            .note("First USD transaction".to_string())
1241            .run()
1242            .await
1243            .unwrap();
1244
1245        // Create second transaction in EUR currency
1246        let tx2_id = Uuid::new_v4();
1247        let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); // 10 minutes later
1248
1249        let tx2_split1 = Split {
1250            id: Uuid::new_v4(),
1251            tx_id: tx2_id,
1252            account_id: account1_id,
1253            commodity_id: eur_id,
1254            value_num: -85,
1255            value_denom: 1,
1256            reconcile_state: None,
1257            reconcile_date: None,
1258            lot_id: None,
1259        };
1260
1261        let tx2_split2 = Split {
1262            id: Uuid::new_v4(),
1263            tx_id: tx2_id,
1264            account_id: account2_id,
1265            commodity_id: eur_id,
1266            value_num: 85,
1267            value_denom: 1,
1268            reconcile_state: None,
1269            reconcile_date: None,
1270            lot_id: None,
1271        };
1272
1273        let tx2_splits = vec![
1274            FinanceEntity::Split(tx2_split1),
1275            FinanceEntity::Split(tx2_split2),
1276        ];
1277
1278        CreateTransaction::new()
1279            .user_id(user.id)
1280            .splits(tx2_splits)
1281            .id(tx2_id)
1282            .post_date(later)
1283            .enter_date(later)
1284            .note("Second EUR transaction".to_string())
1285            .run()
1286            .await
1287            .unwrap();
1288
1289        // Create price conversion data between EUR and USD
1290        let price1 = Price {
1291            id: Uuid::new_v4(),
1292            date: now,
1293            commodity_id: eur_id,
1294            currency_id: usd_id,
1295            commodity_split: None, // General price, not tied to specific splits
1296            currency_split: None,
1297            value_num: 1176, // 1.176 USD per EUR (as rational: 1176/1000)
1298            value_denom: 1000,
1299        };
1300
1301        // Insert price manually using raw SQL since we don't have a CreatePrice command
1302        let mut conn = user.get_connection().await.unwrap();
1303        sqlx::query_file!(
1304            "sql/insert/prices/price.sql",
1305            price1.id,
1306            price1.commodity_id,
1307            price1.currency_id,
1308            price1.commodity_split,
1309            price1.currency_split,
1310            price1.date,
1311            price1.value_num,
1312            price1.value_denom
1313        )
1314        .execute(&mut *conn)
1315        .await
1316        .unwrap();
1317
1318        // Test Account1 balance (mixed currencies - should return MultiCurrencyBalance without commodity_id)
1319        let balance_result1 = GetBalance::new()
1320            .user_id(user.id)
1321            .account_id(account1_id)
1322            .run()
1323            .await
1324            .unwrap();
1325
1326        // Should return MultiCurrencyBalance due to mixed currencies
1327        match balance_result1 {
1328            Some(CmdResult::MultiCurrencyBalance(balances)) => {
1329                assert_eq!(
1330                    balances.len(),
1331                    2,
1332                    "Account1 should have two currency balances"
1333                );
1334            }
1335            _ => panic!("Expected MultiCurrencyBalance result for account1"),
1336        }
1337
1338        // Test Account1 balance in USD (should fail due to missing split-specific conversion)
1339        let balance_result2 = GetBalance::new()
1340            .user_id(user.id)
1341            .account_id(account1_id)
1342            .commodity_id(usd_id)
1343            .run()
1344            .await;
1345
1346        // Should fail because the price record is not split-specific
1347        assert!(
1348            balance_result2.is_err(),
1349            "Expected error for missing split-specific EUR->USD conversion"
1350        );
1351
1352        // Verify it's the right kind of error
1353        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
1354            from_commodity,
1355            to_commodity,
1356            ..
1357        }))) = balance_result2
1358        {
1359            assert_eq!(from_commodity, "EUR");
1360            assert_eq!(to_commodity, "USD");
1361        } else {
1362            panic!("Expected MissingConversion error");
1363        }
1364
1365        // Test Account2 balance (mixed currencies - should return MultiCurrencyBalance without commodity_id)
1366        let balance_result3 = GetBalance::new()
1367            .user_id(user.id)
1368            .account_id(account2_id)
1369            .run()
1370            .await
1371            .unwrap();
1372
1373        // Should return MultiCurrencyBalance due to mixed currencies
1374        match balance_result3 {
1375            Some(CmdResult::MultiCurrencyBalance(balances)) => {
1376                assert_eq!(
1377                    balances.len(),
1378                    2,
1379                    "Account2 should have two currency balances"
1380                );
1381            }
1382            _ => panic!("Expected MultiCurrencyBalance result for account2"),
1383        }
1384
1385        // Test Account2 balance in EUR (should fail due to missing split-specific conversion)
1386        let balance_result4 = GetBalance::new()
1387            .user_id(user.id)
1388            .account_id(account2_id)
1389            .commodity_id(eur_id)
1390            .run()
1391            .await;
1392
1393        // Should fail because USD->EUR conversion is not available (split-specific)
1394        assert!(
1395            balance_result4.is_err(),
1396            "Expected error for missing split-specific USD->EUR conversion"
1397        );
1398
1399        // Verify it's the right kind of error
1400        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
1401            from_commodity,
1402            to_commodity,
1403            ..
1404        }))) = balance_result4
1405        {
1406            assert_eq!(from_commodity, "USD");
1407            assert_eq!(to_commodity, "EUR");
1408        } else {
1409            panic!("Expected MissingConversion error");
1410        }
1411
1412        // Test currency conversion - get Account2 balance in USD (should fail due to missing split-specific conversion)
1413        let balance_result5 = GetBalance::new()
1414            .user_id(user.id)
1415            .account_id(account2_id)
1416            .commodity_id(usd_id) // Convert to USD
1417            .run()
1418            .await;
1419
1420        // Should fail because EUR->USD conversion is not available (split-specific)
1421        assert!(
1422            balance_result5.is_err(),
1423            "Expected error for missing split-specific EUR->USD conversion"
1424        );
1425
1426        // Verify it's the right kind of error
1427        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
1428            from_commodity,
1429            to_commodity,
1430            ..
1431        }))) = balance_result5
1432        {
1433            assert_eq!(from_commodity, "EUR");
1434            assert_eq!(to_commodity, "USD");
1435        } else {
1436            panic!("Expected MissingConversion error for USD conversion");
1437        }
1438    }
1439
1440    #[local_db_sqlx_test]
1441    async fn test_account_balance_without_price_data(pool: PgPool) {
1442        setup().await;
1443        let user = USER.get().unwrap();
1444        user.commit()
1445            .await
1446            .expect("Failed to commit user to database");
1447
1448        // Create commodities and accounts
1449        let usd_result = CreateCommodity::new()
1450            .symbol("USD".to_string())
1451            .name("US Dollar".to_string())
1452            .user_id(user.id)
1453            .run()
1454            .await
1455            .unwrap();
1456
1457        let eur_result = CreateCommodity::new()
1458            .symbol("EUR".to_string())
1459            .name("Euro".to_string())
1460            .user_id(user.id)
1461            .run()
1462            .await
1463            .unwrap();
1464
1465        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
1466            Uuid::parse_str(&id).unwrap()
1467        } else {
1468            panic!("Expected USD commodity ID");
1469        };
1470
1471        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
1472            Uuid::parse_str(&id).unwrap()
1473        } else {
1474            panic!("Expected EUR commodity ID");
1475        };
1476
1477        let account_result = CreateAccount::new()
1478            .name("Mixed Account".to_string())
1479            .user_id(user.id)
1480            .run()
1481            .await
1482            .unwrap();
1483
1484        let account_id =
1485            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account_result {
1486                acc.id
1487            } else {
1488                panic!("Expected account");
1489            };
1490
1491        // Create a second account to make balanced transactions
1492        let account2_result = CreateAccount::new()
1493            .name("Second Account".to_string())
1494            .user_id(user.id)
1495            .run()
1496            .await
1497            .unwrap();
1498
1499        let account2_id =
1500            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account2_result {
1501                acc.id
1502            } else {
1503                panic!("Expected second account");
1504            };
1505
1506        // Create first transaction (USD) WITHOUT price data
1507        let tx1_id = Uuid::new_v4();
1508        let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap();
1509
1510        let split1 = Split {
1511            id: Uuid::new_v4(),
1512            tx_id: tx1_id,
1513            account_id,
1514            commodity_id: usd_id,
1515            value_num: 100,
1516            value_denom: 1,
1517            reconcile_state: None,
1518            reconcile_date: None,
1519            lot_id: None,
1520        };
1521
1522        let split1_balance = Split {
1523            id: Uuid::new_v4(),
1524            tx_id: tx1_id,
1525            account_id: account2_id,
1526            commodity_id: usd_id,
1527            value_num: -100,
1528            value_denom: 1,
1529            reconcile_state: None,
1530            reconcile_date: None,
1531            lot_id: None,
1532        };
1533
1534        let splits1 = vec![
1535            FinanceEntity::Split(split1),
1536            FinanceEntity::Split(split1_balance),
1537        ];
1538        CreateTransaction::new()
1539            .user_id(user.id)
1540            .splits(splits1)
1541            .id(tx1_id)
1542            .post_date(now)
1543            .enter_date(now)
1544            .note("USD transaction without price data".to_string())
1545            .run()
1546            .await
1547            .unwrap();
1548
1549        // Create second transaction (EUR) WITHOUT price data
1550        let tx2_id = Uuid::new_v4();
1551
1552        let split2 = Split {
1553            id: Uuid::new_v4(),
1554            tx_id: tx2_id,
1555            account_id,
1556            commodity_id: eur_id,
1557            value_num: -85,
1558            value_denom: 1,
1559            reconcile_state: None,
1560            reconcile_date: None,
1561            lot_id: None,
1562        };
1563
1564        let split2_balance = Split {
1565            id: Uuid::new_v4(),
1566            tx_id: tx2_id,
1567            account_id: account2_id,
1568            commodity_id: eur_id,
1569            value_num: 85,
1570            value_denom: 1,
1571            reconcile_state: None,
1572            reconcile_date: None,
1573            lot_id: None,
1574        };
1575
1576        let splits2 = vec![
1577            FinanceEntity::Split(split2),
1578            FinanceEntity::Split(split2_balance),
1579        ];
1580        CreateTransaction::new()
1581            .user_id(user.id)
1582            .splits(splits2)
1583            .id(tx2_id)
1584            .post_date(now)
1585            .enter_date(now)
1586            .note("EUR transaction without price data".to_string())
1587            .run()
1588            .await
1589            .unwrap();
1590
1591        // Test balance calculation - should fail due to missing conversion
1592        let balance_result = GetBalance::new()
1593            .user_id(user.id)
1594            .account_id(account_id)
1595            .commodity_id(usd_id) // Try to get balance in USD
1596            .run()
1597            .await;
1598
1599        // Should fail due to missing EUR->USD price conversion
1600        assert!(
1601            balance_result.is_err(),
1602            "Expected error for missing EUR->USD conversion"
1603        );
1604
1605        // Verify it's the right kind of error
1606        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
1607            from_commodity,
1608            to_commodity,
1609            ..
1610        }))) = balance_result
1611        {
1612            assert_eq!(from_commodity, "EUR");
1613            assert_eq!(to_commodity, "USD");
1614        } else {
1615            panic!("Expected MissingConversion error");
1616        }
1617    }
1618
1619    #[local_db_sqlx_test]
1620    async fn test_get_balance_single_currency(pool: PgPool) {
1621        setup().await;
1622        let user = USER.get().unwrap();
1623        user.commit()
1624            .await
1625            .expect("Failed to commit user to database");
1626
1627        // Create a commodity
1628        let commodity_result = CreateCommodity::new()
1629            .symbol("USD".to_string())
1630            .name("US Dollar".to_string())
1631            .user_id(user.id)
1632            .run()
1633            .await
1634            .expect("Failed to create commodity");
1635
1636        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1637            Uuid::parse_str(&id).expect("Failed to parse commodity ID")
1638        } else {
1639            panic!("Expected commodity ID string result");
1640        };
1641
1642        // Create two accounts
1643        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1644            CreateAccount::new()
1645                .name("Test Account 1".to_string())
1646                .user_id(user.id)
1647                .run()
1648                .await
1649                .expect("Test operation failed")
1650        {
1651            account
1652        } else {
1653            panic!("Expected account entity result");
1654        };
1655
1656        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1657            CreateAccount::new()
1658                .name("Test Account 2".to_string())
1659                .user_id(user.id)
1660                .run()
1661                .await
1662                .expect("Test operation failed")
1663        {
1664            account
1665        } else {
1666            panic!("Expected account entity result");
1667        };
1668
1669        // Create a transaction: 100 USD from account1 to account2
1670        let tx_id = Uuid::new_v4();
1671        let now = Utc::now();
1672
1673        let split1 = Split {
1674            id: Uuid::new_v4(),
1675            tx_id,
1676            account_id: account1.id,
1677            commodity_id,
1678            value_num: -100,
1679            value_denom: 1,
1680            reconcile_state: None,
1681            reconcile_date: None,
1682            lot_id: None,
1683        };
1684
1685        let split2 = Split {
1686            id: Uuid::new_v4(),
1687            tx_id,
1688            account_id: account2.id,
1689            commodity_id,
1690            value_num: 100,
1691            value_denom: 1,
1692            reconcile_state: None,
1693            reconcile_date: None,
1694            lot_id: None,
1695        };
1696
1697        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1698        CreateTransaction::new()
1699            .user_id(user.id)
1700            .splits(splits)
1701            .id(tx_id)
1702            .post_date(now)
1703            .enter_date(now)
1704            .run()
1705            .await
1706            .expect("Test operation failed");
1707
1708        // Test balance for account1 (should be -100)
1709        let balance_result1 = GetBalance::new()
1710            .user_id(user.id)
1711            .account_id(account1.id)
1712            .run()
1713            .await
1714            .expect("Test operation failed");
1715
1716        if let Some(CmdResult::Rational(balance)) = balance_result1 {
1717            assert_eq!(
1718                balance,
1719                Rational64::new(-100, 1),
1720                "Account1 balance should be -100, got: {balance}"
1721            );
1722        } else {
1723            panic!("Expected rational balance result for account1");
1724        }
1725
1726        // Test balance for account2 (should be 100)
1727        let balance_result2 = GetBalance::new()
1728            .user_id(user.id)
1729            .account_id(account2.id)
1730            .run()
1731            .await
1732            .expect("Test operation failed");
1733
1734        if let Some(CmdResult::Rational(balance)) = balance_result2 {
1735            assert_eq!(
1736                balance,
1737                Rational64::new(100, 1),
1738                "Account2 balance should be 100, got: {balance}"
1739            );
1740        } else {
1741            panic!("Expected rational balance result for account2");
1742        }
1743
1744        // Test balance with explicit commodity_id (should work the same)
1745        let balance_result3 = GetBalance::new()
1746            .user_id(user.id)
1747            .account_id(account1.id)
1748            .commodity_id(commodity_id)
1749            .run()
1750            .await
1751            .expect("Test operation failed");
1752
1753        if let Some(CmdResult::Rational(balance)) = balance_result3 {
1754            assert_eq!(
1755                balance,
1756                Rational64::new(-100, 1),
1757                "Account1 balance with explicit commodity_id should be -100, got: {balance}"
1758            );
1759        } else {
1760            panic!("Expected rational balance result with explicit commodity_id");
1761        }
1762    }
1763
1764    #[local_db_sqlx_test]
1765    async fn test_get_balance_empty_account(pool: PgPool) {
1766        setup().await;
1767        let user = USER.get().unwrap();
1768        user.commit()
1769            .await
1770            .expect("Failed to commit user to database");
1771
1772        // Create an account with no transactions
1773        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1774            CreateAccount::new()
1775                .name("Empty Account".to_string())
1776                .user_id(user.id)
1777                .run()
1778                .await
1779                .expect("Test operation failed")
1780        {
1781            account
1782        } else {
1783            panic!("Expected account entity result");
1784        };
1785
1786        // Test balance for empty account (should be 0)
1787        let balance_result = GetBalance::new()
1788            .user_id(user.id)
1789            .account_id(account.id)
1790            .run()
1791            .await
1792            .expect("Test operation failed");
1793
1794        if let Some(CmdResult::Rational(balance)) = balance_result {
1795            assert_eq!(
1796                balance,
1797                Rational64::new(0, 1),
1798                "Empty account balance should be 0, got: {balance}"
1799            );
1800        } else {
1801            panic!("Expected rational balance result for empty account");
1802        }
1803    }
1804
1805    #[local_db_sqlx_test]
1806    async fn test_get_balance_mixed_currencies_error(pool: PgPool) {
1807        setup().await;
1808        let user = USER.get().unwrap();
1809        user.commit()
1810            .await
1811            .expect("Failed to commit user to database");
1812
1813        // Create two commodities
1814        let usd_result = CreateCommodity::new()
1815            .symbol("USD".to_string())
1816            .name("US Dollar".to_string())
1817            .user_id(user.id)
1818            .run()
1819            .await
1820            .expect("Test operation failed");
1821
1822        let eur_result = CreateCommodity::new()
1823            .symbol("EUR".to_string())
1824            .name("Euro".to_string())
1825            .user_id(user.id)
1826            .run()
1827            .await
1828            .expect("Test operation failed");
1829
1830        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
1831            Uuid::parse_str(&id).expect("Failed to parse USD commodity ID")
1832        } else {
1833            panic!("Expected USD commodity ID");
1834        };
1835
1836        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
1837            Uuid::parse_str(&id).expect("Failed to parse EUR commodity ID")
1838        } else {
1839            panic!("Expected EUR commodity ID");
1840        };
1841
1842        // Create three accounts
1843        let mixed_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1844            CreateAccount::new()
1845                .name("Mixed Currency Account".to_string())
1846                .user_id(user.id)
1847                .run()
1848                .await
1849                .expect("Test operation failed")
1850        {
1851            account
1852        } else {
1853            panic!("Expected mixed account entity result");
1854        };
1855
1856        let usd_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1857            CreateAccount::new()
1858                .name("USD Account".to_string())
1859                .user_id(user.id)
1860                .run()
1861                .await
1862                .expect("Test operation failed")
1863        {
1864            account
1865        } else {
1866            panic!("Expected USD account entity result");
1867        };
1868
1869        let eur_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1870            CreateAccount::new()
1871                .name("EUR Account".to_string())
1872                .user_id(user.id)
1873                .run()
1874                .await
1875                .expect("Test operation failed")
1876        {
1877            account
1878        } else {
1879            panic!("Expected EUR account entity result");
1880        };
1881
1882        // Create first transaction: 100 USD to mixed_account
1883        let tx1_id = Uuid::new_v4();
1884        let now = Utc::now();
1885
1886        let splits1 = vec![
1887            FinanceEntity::Split(Split {
1888                id: Uuid::new_v4(),
1889                tx_id: tx1_id,
1890                account_id: mixed_account.id,
1891                commodity_id: usd_id,
1892                value_num: 100,
1893                value_denom: 1,
1894                reconcile_state: None,
1895                reconcile_date: None,
1896                lot_id: None,
1897            }),
1898            FinanceEntity::Split(Split {
1899                id: Uuid::new_v4(),
1900                tx_id: tx1_id,
1901                account_id: usd_account.id,
1902                commodity_id: usd_id,
1903                value_num: -100,
1904                value_denom: 1,
1905                reconcile_state: None,
1906                reconcile_date: None,
1907                lot_id: None,
1908            }),
1909        ];
1910
1911        CreateTransaction::new()
1912            .user_id(user.id)
1913            .splits(splits1)
1914            .id(tx1_id)
1915            .post_date(now)
1916            .enter_date(now)
1917            .run()
1918            .await
1919            .expect("Test operation failed");
1920
1921        // Create second transaction: 50 EUR to mixed_account
1922        let tx2_id = Uuid::new_v4();
1923
1924        let splits2 = vec![
1925            FinanceEntity::Split(Split {
1926                id: Uuid::new_v4(),
1927                tx_id: tx2_id,
1928                account_id: mixed_account.id,
1929                commodity_id: eur_id,
1930                value_num: 50,
1931                value_denom: 1,
1932                reconcile_state: None,
1933                reconcile_date: None,
1934                lot_id: None,
1935            }),
1936            FinanceEntity::Split(Split {
1937                id: Uuid::new_v4(),
1938                tx_id: tx2_id,
1939                account_id: eur_account.id,
1940                commodity_id: eur_id,
1941                value_num: -50,
1942                value_denom: 1,
1943                reconcile_state: None,
1944                reconcile_date: None,
1945                lot_id: None,
1946            }),
1947        ];
1948
1949        CreateTransaction::new()
1950            .user_id(user.id)
1951            .splits(splits2)
1952            .id(tx2_id)
1953            .post_date(now)
1954            .enter_date(now)
1955            .run()
1956            .await
1957            .expect("Test operation failed");
1958
1959        // Test balance for mixed_account without commodity_id (should return MultiCurrencyBalance)
1960        let balance_result = GetBalance::new()
1961            .user_id(user.id)
1962            .account_id(mixed_account.id)
1963            .run()
1964            .await
1965            .expect("Test operation failed");
1966
1967        if let Some(CmdResult::MultiCurrencyBalance(balances)) = balance_result {
1968            assert_eq!(balances.len(), 2, "Expected two currency balances");
1969            // USD balance should be 100, EUR balance should be 50
1970            for (commodity, balance) in balances {
1971                match balance.to_integer() {
1972                    100 => {
1973                        // USD balance
1974                        assert_eq!(commodity.id, usd_id);
1975                    }
1976                    50 => {
1977                        // EUR balance
1978                        assert_eq!(commodity.id, eur_id);
1979                    }
1980                    _ => panic!("Unexpected balance: {balance}"),
1981                }
1982            }
1983        } else {
1984            panic!("Expected MultiCurrencyBalance result for mixed currencies");
1985        }
1986
1987        // Test balance with specific commodity_id (should error due to missing conversion)
1988        let balance_result_usd = GetBalance::new()
1989            .user_id(user.id)
1990            .account_id(mixed_account.id)
1991            .commodity_id(usd_id)
1992            .run()
1993            .await;
1994
1995        // Should fail due to missing EUR->USD price conversion
1996        assert!(
1997            balance_result_usd.is_err(),
1998            "Expected error for missing EUR->USD conversion"
1999        );
2000
2001        // Verify it's the right kind of error
2002        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
2003            from_commodity,
2004            to_commodity,
2005            ..
2006        }))) = balance_result_usd
2007        {
2008            assert_eq!(from_commodity, "EUR");
2009            assert_eq!(to_commodity, "USD");
2010        } else {
2011            panic!("Expected MissingConversion error");
2012        }
2013    }
2014
2015    #[local_db_sqlx_test]
2016    async fn test_cross_account_balance_isolation(pool: PgPool) {
2017        setup().await;
2018        let user = USER.get().unwrap();
2019        user.commit()
2020            .await
2021            .expect("Failed to commit user to database");
2022
2023        // Create commodities
2024        let usd_result = CreateCommodity::new()
2025            .symbol("USD".to_string())
2026            .name("US Dollar".to_string())
2027            .user_id(user.id)
2028            .run()
2029            .await
2030            .unwrap();
2031
2032        let eur_result = CreateCommodity::new()
2033            .symbol("EUR".to_string())
2034            .name("Euro".to_string())
2035            .user_id(user.id)
2036            .run()
2037            .await
2038            .unwrap();
2039
2040        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
2041            Uuid::parse_str(&id).unwrap()
2042        } else {
2043            panic!("Expected USD commodity ID");
2044        };
2045
2046        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
2047            Uuid::parse_str(&id).unwrap()
2048        } else {
2049            panic!("Expected EUR commodity ID");
2050        };
2051
2052        // Create two accounts
2053        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) =
2054            CreateAccount::new()
2055                .name("Account1".to_string())
2056                .user_id(user.id)
2057                .run()
2058                .await
2059                .unwrap()
2060        {
2061            acc
2062        } else {
2063            panic!("Expected account1");
2064        };
2065
2066        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) =
2067            CreateAccount::new()
2068                .name("Account2".to_string())
2069                .user_id(user.id)
2070                .run()
2071                .await
2072                .unwrap()
2073        {
2074            acc
2075        } else {
2076            panic!("Expected account2");
2077        };
2078
2079        // Transaction 1: 100 USD from account1 to account2
2080        let tx1_id = Uuid::new_v4();
2081        let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap();
2082
2083        let splits1 = vec![
2084            FinanceEntity::Split(Split {
2085                id: Uuid::new_v4(),
2086                tx_id: tx1_id,
2087                account_id: account1.id,
2088                commodity_id: usd_id,
2089                value_num: -100,
2090                value_denom: 1,
2091                reconcile_state: None,
2092                reconcile_date: None,
2093                lot_id: None,
2094            }),
2095            FinanceEntity::Split(Split {
2096                id: Uuid::new_v4(),
2097                tx_id: tx1_id,
2098                account_id: account2.id,
2099                commodity_id: usd_id,
2100                value_num: 100,
2101                value_denom: 1,
2102                reconcile_state: None,
2103                reconcile_date: None,
2104                lot_id: None,
2105            }),
2106        ];
2107
2108        CreateTransaction::new()
2109            .user_id(user.id)
2110            .splits(splits1)
2111            .id(tx1_id)
2112            .post_date(now)
2113            .enter_date(now)
2114            .run()
2115            .await
2116            .unwrap();
2117
2118        // Check initial balances (should be simple single-currency)
2119        let balance1_initial = GetBalance::new()
2120            .user_id(user.id)
2121            .account_id(account1.id)
2122            .run()
2123            .await
2124            .unwrap();
2125
2126        let balance2_initial = GetBalance::new()
2127            .user_id(user.id)
2128            .account_id(account2.id)
2129            .run()
2130            .await
2131            .unwrap();
2132
2133        // Both should have simple USD balances
2134        match balance1_initial {
2135            Some(CmdResult::Rational(balance)) => {
2136                assert_eq!(balance, Rational64::new(-100, 1));
2137            }
2138            _ => panic!("Expected Rational balance result for account1"),
2139        }
2140        match balance2_initial {
2141            Some(CmdResult::Rational(balance)) => {
2142                assert_eq!(balance, Rational64::new(100, 1));
2143            }
2144            _ => panic!("Expected Rational balance result for account2"),
2145        }
2146
2147        // Transaction 2: Add EUR transaction - 50 EUR from account1 to account2
2148        let tx2_id = Uuid::new_v4();
2149        let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); // 10 minutes later
2150
2151        let splits2 = vec![
2152            FinanceEntity::Split(Split {
2153                id: Uuid::new_v4(),
2154                tx_id: tx2_id,
2155                account_id: account1.id,
2156                commodity_id: eur_id,
2157                value_num: -50,
2158                value_denom: 1,
2159                reconcile_state: None,
2160                reconcile_date: None,
2161                lot_id: None,
2162            }),
2163            FinanceEntity::Split(Split {
2164                id: Uuid::new_v4(),
2165                tx_id: tx2_id,
2166                account_id: account2.id,
2167                commodity_id: eur_id,
2168                value_num: 50,
2169                value_denom: 1,
2170                reconcile_state: None,
2171                reconcile_date: None,
2172                lot_id: None,
2173            }),
2174        ];
2175
2176        CreateTransaction::new()
2177            .user_id(user.id)
2178            .splits(splits2)
2179            .id(tx2_id)
2180            .post_date(later)
2181            .enter_date(later)
2182            .run()
2183            .await
2184            .unwrap();
2185
2186        // Now both accounts have mixed currencies and should return MultiCurrencyBalance
2187        let balance1_after = GetBalance::new()
2188            .user_id(user.id)
2189            .account_id(account1.id)
2190            .run()
2191            .await
2192            .unwrap();
2193
2194        let balance2_after = GetBalance::new()
2195            .user_id(user.id)
2196            .account_id(account2.id)
2197            .run()
2198            .await
2199            .unwrap();
2200
2201        // Both should return MultiCurrencyBalance due to mixed currencies
2202        match balance1_after {
2203            Some(CmdResult::MultiCurrencyBalance(balances)) => {
2204                assert_eq!(
2205                    balances.len(),
2206                    2,
2207                    "Account1 should have two currency balances"
2208                );
2209            }
2210            _ => panic!("Expected MultiCurrencyBalance result for account1"),
2211        }
2212        match balance2_after {
2213            Some(CmdResult::MultiCurrencyBalance(balances)) => {
2214                assert_eq!(
2215                    balances.len(),
2216                    2,
2217                    "Account2 should have two currency balances"
2218                );
2219            }
2220            _ => panic!("Expected MultiCurrencyBalance result for account2"),
2221        }
2222
2223        // Test Account2 balance in EUR before adding account1-only transaction
2224        let balance2_eur_before = GetBalance::new()
2225            .user_id(user.id)
2226            .account_id(account2.id)
2227            .commodity_id(eur_id)
2228            .run()
2229            .await;
2230
2231        // Should fail due to missing USD->EUR price conversion
2232        assert!(
2233            balance2_eur_before.is_err(),
2234            "Expected error for missing USD->EUR conversion"
2235        );
2236
2237        // Verify it's the right kind of error
2238        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
2239            from_commodity,
2240            to_commodity,
2241            ..
2242        }))) = balance2_eur_before
2243        {
2244            assert_eq!(from_commodity, "USD");
2245            assert_eq!(to_commodity, "EUR");
2246        } else {
2247            panic!("Expected MissingConversion error");
2248        }
2249
2250        // Now add a third transaction affecting only account1 (not account2)
2251        let tx3_id = Uuid::new_v4();
2252        let even_later = DateTime::<Utc>::from_timestamp(1640996400, 0).unwrap(); // 20 minutes later
2253
2254        let account3 = if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) =
2255            CreateAccount::new()
2256                .name("Account3".to_string())
2257                .user_id(user.id)
2258                .run()
2259                .await
2260                .unwrap()
2261        {
2262            acc
2263        } else {
2264            panic!("Expected account3");
2265        };
2266
2267        let splits3 = vec![
2268            FinanceEntity::Split(Split {
2269                id: Uuid::new_v4(),
2270                tx_id: tx3_id,
2271                account_id: account1.id,
2272                commodity_id: usd_id,
2273                value_num: -25,
2274                value_denom: 1,
2275                reconcile_state: None,
2276                reconcile_date: None,
2277                lot_id: None,
2278            }),
2279            FinanceEntity::Split(Split {
2280                id: Uuid::new_v4(),
2281                tx_id: tx3_id,
2282                account_id: account3.id,
2283                commodity_id: usd_id,
2284                value_num: 25,
2285                value_denom: 1,
2286                reconcile_state: None,
2287                reconcile_date: None,
2288                lot_id: None,
2289            }),
2290        ];
2291
2292        CreateTransaction::new()
2293            .user_id(user.id)
2294            .splits(splits3)
2295            .id(tx3_id)
2296            .post_date(even_later)
2297            .enter_date(even_later)
2298            .run()
2299            .await
2300            .unwrap();
2301
2302        // CRITICAL TEST: Account2's balance should still fail due to missing USD->EUR conversion
2303        // This verifies that account1's new transaction doesn't affect account2's error condition
2304        let balance2_eur_after = GetBalance::new()
2305            .user_id(user.id)
2306            .account_id(account2.id)
2307            .commodity_id(eur_id)
2308            .run()
2309            .await;
2310
2311        // Should still fail due to missing USD->EUR price conversion, unchanged by account1's new transaction
2312        assert!(
2313            balance2_eur_after.is_err(),
2314            "Expected error for missing USD->EUR conversion (account isolation test)"
2315        );
2316
2317        // Verify it's still the same kind of error
2318        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
2319            from_commodity,
2320            to_commodity,
2321            ..
2322        }))) = balance2_eur_after
2323        {
2324            assert_eq!(from_commodity, "USD");
2325            assert_eq!(to_commodity, "EUR");
2326        } else {
2327            panic!("Expected MissingConversion error");
2328        }
2329    }
2330}