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            let account = Account {
52                id: row.id,
53                parent: row.parent_id,
54            };
55
56            // Get tags for this account
57            let tags: HashMap<String, FinanceEntity> =
58                sqlx::query_file!("sql/select/tags/by_account.sql", &account.id)
59                    .fetch_all(&mut *conn)
60                    .await?
61                    .into_iter()
62                    .map(|row| {
63                        (
64                            row.tag_name.clone(),
65                            FinanceEntity::Tag(Tag {
66                                id: row.id,
67                                tag_name: row.tag_name,
68                                tag_value: row.tag_value,
69                                description: row.description,
70                            }),
71                        )
72                    })
73                    .collect();
74
75            // Now push them, no need to borrow from the vector.
76            tagged_accounts.push((FinanceEntity::Account(account), tags));
77        }
78
79        Ok(Some(CmdResult::TaggedEntities {
80            entities: tagged_accounts,
81            pagination: None,
82        }))
83    }
84}
85
86command! {
87    ListAccountsForManage {
88        #[required]
89        user_id: Uuid,
90    } => {
91        let user = User { id: user_id };
92        let mut conn = user.get_connection().await.map_err(|err| {
93            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
94            ConfigError::DB
95        })?;
96
97        let rows = sqlx::query_file!("sql/select/accounts/manage_tree.sql")
98            .fetch_all(&mut *conn)
99            .await?;
100
101        let mut tagged_accounts = Vec::new();
102        for row in rows {
103            let account = Account {
104                id: row.id,
105                parent: row.parent_id,
106            };
107
108            let tags: HashMap<String, FinanceEntity> =
109                sqlx::query_file!("sql/select/tags/by_account.sql", &account.id)
110                    .fetch_all(&mut *conn)
111                    .await?
112                    .into_iter()
113                    .map(|tag_row| {
114                        (
115                            tag_row.tag_name.clone(),
116                            FinanceEntity::Tag(Tag {
117                                id: tag_row.id,
118                                tag_name: tag_row.tag_name,
119                                tag_value: tag_row.tag_value,
120                                description: tag_row.description,
121                            }),
122                        )
123                    })
124                    .collect();
125
126            tagged_accounts.push((FinanceEntity::Account(account), tags));
127        }
128
129        Ok(Some(CmdResult::TaggedEntities {
130            entities: tagged_accounts,
131            pagination: None,
132        }))
133    }
134}
135
136command! {
137    GetAccountForManage {
138        #[required]
139        user_id: Uuid,
140        #[required]
141        account_id: Uuid,
142    } => {
143        let user = User { id: user_id };
144        let mut conn = user.get_connection().await.map_err(|err| {
145            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
146            ConfigError::DB
147        })?;
148
149        let row = sqlx::query_file!("sql/select/accounts/manage_details.sql", &account_id)
150            .fetch_optional(&mut *conn)
151            .await?;
152
153        let Some(row) = row else {
154            return Ok(Some(CmdResult::TaggedEntities {
155                entities: vec![],
156                pagination: None,
157            }));
158        };
159
160        let account = Account {
161            id: row.id,
162            parent: row.parent_id,
163        };
164
165        let tags: HashMap<String, FinanceEntity> =
166            sqlx::query_file!("sql/select/tags/by_account.sql", &account.id)
167                .fetch_all(&mut *conn)
168                .await?
169                .into_iter()
170                .map(|tag_row| {
171                    (
172                        tag_row.tag_name.clone(),
173                        FinanceEntity::Tag(Tag {
174                            id: tag_row.id,
175                            tag_name: tag_row.tag_name,
176                            tag_value: tag_row.tag_value,
177                            description: tag_row.description,
178                        }),
179                    )
180                })
181                .collect();
182
183        Ok(Some(CmdResult::TaggedEntities {
184            entities: vec![(FinanceEntity::Account(account), tags)],
185            pagination: None,
186        }))
187    }
188}
189
190command! {
191    SetAccountTag {
192        #[required]
193        user_id: Uuid,
194        #[required]
195        account_id: Uuid,
196        #[required]
197        tag_name: String,
198        #[required]
199        tag_value: String,
200        #[optional]
201        description: String,
202    } => {
203        let user = User { id: user_id };
204        let mut conn = user.get_connection().await.map_err(|err| {
205            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
206            ConfigError::DB
207        })?;
208
209        let account_row = sqlx::query_file!("sql/select/accounts/by_id.sql", &account_id)
210            .fetch_optional(&mut *conn)
211            .await?;
212
213        let Some(account_row) = account_row else {
214            return Err(CmdError::Args("Account not found".to_string()));
215        };
216
217        let account = Account {
218            id: account_row.id,
219            parent: account_row.parent,
220        };
221
222        let desc = description.and_then(|text| {
223            if text.trim().is_empty() {
224                None
225            } else {
226                Some(text)
227            }
228        });
229
230        let tag = Tag {
231            id: Uuid::new_v4(),
232            tag_name,
233            tag_value,
234            description: desc,
235        };
236
237        user.set_account_tag(&account, &tag).await?;
238
239        Ok(Some(CmdResult::String("ok".to_string())))
240    }
241}
242
243command! {
244    GetAccount {
245        #[required]
246        user_id: Uuid,
247        #[optional]
248        account_id: Uuid,
249        #[optional]
250        account_name: String,
251    } => {
252        let user = User { id: user_id };
253        let mut conn = user.get_connection().await.map_err(|err| {
254            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
255            ConfigError::DB
256        })?;
257
258        // Get account by ID or name
259        let mut account_id_option: Option<Uuid> = None;
260
261        if let Some(aid) = account_id {
262            account_id_option = Some(aid);
263        } else if let Some(name) = account_name {
264            // Search by name tag
265            let account_row = sqlx::query_file!("sql/select/accounts/by_name.sql", &name)
266                .fetch_optional(&mut *conn)
267                .await?;
268
269            if let Some(row) = account_row {
270                account_id_option = Some(row.id);
271            }
272        } else {
273            return Err(CmdError::Args(
274                "Either account_id or account_name must be provided".to_string(),
275            ));
276        }
277
278        // Now fetch the account by ID if we found one
279        let account_query = if let Some(account_id) = account_id_option {
280            sqlx::query_file!("sql/select/accounts/by_id.sql", &account_id)
281                .fetch_optional(&mut *conn)
282                .await?
283        } else {
284            None
285        };
286
287        // If account found, get its tags
288        if let Some(row) = account_query {
289            let account = Account {
290                id: row.id,
291                parent: row.parent,
292            };
293
294            // Get tags for this account
295            let tags: HashMap<String, FinanceEntity> =
296                sqlx::query_file!("sql/select/tags/by_account.sql", &account.id)
297                    .fetch_all(&mut *conn)
298                    .await?
299                    .into_iter()
300                    .map(|row| {
301                        (
302                            row.tag_name.clone(),
303                            FinanceEntity::Tag(Tag {
304                                id: row.id,
305                                tag_name: row.tag_name,
306                                tag_value: row.tag_value,
307                                description: row.description,
308                            }),
309                        )
310                    })
311                    .collect();
312
313            let tagged_account = vec![(FinanceEntity::Account(account), tags)];
314            Ok(Some(CmdResult::TaggedEntities {
315                entities: tagged_account,
316                pagination: None,
317            }))
318        } else {
319            // No account found
320            Ok(Some(CmdResult::TaggedEntities {
321                entities: vec![],
322                pagination: None,
323            }))
324        }
325    }
326}
327
328command! {
329    GetAccountCommodities {
330        #[required]
331        user_id: Uuid,
332        #[required]
333        account_id: Uuid,
334    } => {
335        let user = User { id: user_id };
336        let mut conn = user.get_connection().await.map_err(|err| {
337            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
338            ConfigError::DB
339        })?;
340
341        // Get unique commodities for this account
342        let rows = sqlx::query_file!("sql/select/accounts/commodities.sql", &account_id)
343            .fetch_all(&mut *conn)
344            .await?;
345
346        // Return the commodity information as structured data
347        let mut commodity_infos = Vec::new();
348        for row in rows {
349            commodity_infos.push(CommodityInfo {
350                commodity_id: row.commodity_id,
351                symbol: row.symbol,
352                name: row.commodity_name,
353            });
354        }
355
356        Ok(Some(CmdResult::CommodityInfoList(commodity_infos)))
357    }
358}
359
360command! {
361    GetBalance {
362        #[required]
363        user_id: Uuid,
364        #[required]
365        account_id: Uuid,
366        #[optional]
367        commodity_id: Uuid,
368    } => {
369        let user = User { id: user_id };
370        let mut conn = user.get_connection().await.map_err(|err| {
371            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
372            ConfigError::DB
373        })?;
374
375        // Get all splits with their conversion information
376        let splits_data = sqlx::query_file!(
377            "sql/balance/accounts/splits/all_with_conversion.sql",
378            &account_id,
379            commodity_id.as_ref()
380        )
381        .fetch_all(&mut *conn)
382        .await?;
383
384        if splits_data.is_empty() {
385            // No splits - return zero balance as rational regardless of currency request
386            return Ok(Some(CmdResult::Rational(Rational64::new(0, 1))));
387        }
388
389        // Check if all splits use the same commodity
390        let unique_commodities: std::collections::HashSet<_> = splits_data.iter().map(|s| s.commodity_id).collect();
391
392        match commodity_id {
393            Some(target_commodity_id) => {
394                // Single currency mode - convert everything to target commodity
395                let mut total_balance = Rational64::new(0, 1);
396
397                for split_data in splits_data {
398                    let split_value = Rational64::new(split_data.value_num, split_data.value_denom);
399
400                    if split_data.commodity_id == target_commodity_id {
401                        // Same commodity, add directly
402                        total_balance += split_value;
403                    } else {
404                        // Different commodity, need conversion
405                        if let (Some(price_num), Some(price_denom)) = (split_data.price_num, split_data.price_denom) {
406                            // Conversion data available
407                            let price_ratio = Rational64::new(price_num, price_denom);
408                            let converted_value = split_value * price_ratio;
409                            total_balance += converted_value;
410                        } else {
411                            // Missing conversion data - get target commodity symbol for error
412                            let to_symbol = sqlx::query_file_scalar!(
413                                "sql/select/commodities/symbol.sql",
414                                &target_commodity_id
415                            )
416                            .fetch_optional(&mut *conn)
417                            .await?
418                            .unwrap_or_else(|| target_commodity_id.to_string());
419
420                            return Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
421                                split_id: split_data.split_id,
422                                from_commodity: split_data.commodity_symbol.clone(),
423                                to_commodity: to_symbol,
424                            })));
425                        }
426                    }
427                }
428
429                Ok(Some(CmdResult::Rational(total_balance)))
430            },
431            None => {
432                // No specific currency requested
433                if unique_commodities.len() == 1 {
434                    // Single currency - return simple rational balance
435                    let total_balance = splits_data.iter()
436                        .map(|split_data| Rational64::new(split_data.value_num, split_data.value_denom))
437                        .sum();
438                    Ok(Some(CmdResult::Rational(total_balance)))
439                } else {
440                    // Multi-currency mode - return balance for each commodity
441                    use std::collections::HashMap;
442                    let mut balances_by_commodity: HashMap<Uuid, (Commodity, Rational64, String)> = HashMap::new();
443
444                    for split_data in splits_data {
445                        let split_value = Rational64::new(split_data.value_num, split_data.value_denom);
446
447                        balances_by_commodity
448                            .entry(split_data.commodity_id)
449                            .and_modify(|(_, balance, _)| *balance += split_value)
450                            .or_insert_with(|| {
451                                let commodity = Commodity {
452                                    id: split_data.commodity_id,
453                                };
454                                (commodity, split_value, split_data.commodity_symbol.clone())
455                            });
456                    }
457
458                    // Convert to sorted vector (sort by symbol)
459                    let mut result: Vec<(Commodity, Rational64)> = balances_by_commodity
460                        .into_values()
461                        .map(|(commodity, balance, _symbol)| (commodity, balance))
462                        .collect();
463                    result.sort_by(|a, b| {
464                        // Sort by commodity_id since we don't have symbol in Commodity struct
465                        a.0.id.cmp(&b.0.id)
466                    });
467
468                    Ok(Some(CmdResult::MultiCurrencyBalance(result)))
469                }
470            }
471        }
472    }
473}
474
475#[cfg(test)]
476mod command_tests {
477    use super::*;
478    use crate::{
479        command::{commodity::CreateCommodity, transaction::CreateTransaction},
480        db::DB_POOL,
481    };
482    use finance::{price::Price, split::Split};
483    use sqlx::{
484        PgPool,
485        types::chrono::{DateTime, Utc},
486    };
487    use supp_macro::local_db_sqlx_test;
488    use tokio::sync::OnceCell;
489
490    /// Context for keeping environment intact
491    static CONTEXT: OnceCell<()> = OnceCell::const_new();
492    static USER: OnceCell<User> = OnceCell::const_new();
493
494    async fn setup() {
495        CONTEXT
496            .get_or_init(|| async {
497                #[cfg(feature = "testlog")]
498                let _ = env_logger::builder()
499                    .is_test(true)
500                    .filter_level(log::LevelFilter::Trace)
501                    .try_init();
502            })
503            .await;
504        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
505            .await;
506    }
507
508    #[local_db_sqlx_test]
509    async fn test_create_account(pool: PgPool) -> anyhow::Result<()> {
510        let user = USER.get().unwrap();
511        user.commit()
512            .await
513            .expect("Failed to commit user to database");
514
515        // First create a commodity
516        let commodity_result = CreateCommodity::new()
517            .symbol("TST".to_string())
518            .name("Test Commodity".to_string())
519            .user_id(user.id)
520            .run()
521            .await?;
522
523        // Get the commodity ID and create a commodity entity
524        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
525            uuid::Uuid::parse_str(&id)?
526        } else {
527            panic!("Expected commodity ID string result");
528        };
529        let _commodity = Commodity { id: commodity_id };
530
531        // Now create an account
532        if let Some(CmdResult::Entity(FinanceEntity::Account(account))) = CreateAccount::new()
533            .name("Test Account".to_string())
534            .user_id(user.id)
535            .run()
536            .await?
537        {
538            assert!(!account.id.is_nil());
539        } else {
540            panic!("Expected account ID string result");
541        }
542    }
543
544    #[local_db_sqlx_test]
545    async fn test_list_accounts_empty(pool: PgPool) -> anyhow::Result<()> {
546        let user = USER.get().unwrap();
547        user.commit()
548            .await
549            .expect("Failed to commit user to database");
550
551        if let Some(CmdResult::TaggedEntities { entities, .. }) =
552            ListAccounts::new().user_id(user.id).run().await?
553        {
554            assert!(
555                entities.is_empty(),
556                "Expected no accounts in empty database"
557            );
558        } else {
559            panic!("Expected TaggedEntities result");
560        }
561    }
562
563    #[local_db_sqlx_test]
564    async fn test_list_accounts_with_data(pool: PgPool) -> anyhow::Result<()> {
565        let user = USER.get().unwrap();
566        user.commit()
567            .await
568            .expect("Failed to commit user to database");
569
570        // First create a commodity
571        let commodity_result = CreateCommodity::new()
572            .symbol("TST".to_string())
573            .name("Test Commodity".to_string())
574            .user_id(user.id)
575            .run()
576            .await?;
577
578        // Get the commodity ID and create a commodity entity
579        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
580            uuid::Uuid::parse_str(&id)?
581        } else {
582            panic!("Expected commodity ID string result");
583        };
584        let _commodity = Commodity { id: commodity_id };
585
586        // Create an account
587        CreateAccount::new()
588            .name("Test Account".to_string())
589            .user_id(user.id)
590            .run()
591            .await?;
592
593        // List accounts
594        if let Some(CmdResult::TaggedEntities { entities, .. }) =
595            ListAccounts::new().user_id(user.id).run().await?
596        {
597            assert_eq!(entities.len(), 1, "Expected one account");
598
599            let (entity, tags) = &entities[0];
600            if let FinanceEntity::Account(_) = entity {
601                // Check tags
602                assert_eq!(tags.len(), 1); // name tag
603                if let FinanceEntity::Tag(tag) = &tags["name"] {
604                    assert_eq!(tag.tag_name, "name");
605                    assert_eq!(tag.tag_value, "Test Account");
606                } else {
607                    panic!("Expected Tag entity");
608                }
609            } else {
610                panic!("Expected Account entity");
611            }
612        } else {
613            panic!("Expected TaggedEntities result");
614        }
615    }
616
617    #[local_db_sqlx_test]
618    async fn test_get_account(pool: PgPool) -> anyhow::Result<()> {
619        let user = USER.get().unwrap();
620        user.commit()
621            .await
622            .expect("Failed to commit user to database");
623
624        // First create a commodity
625        let commodity_result = CreateCommodity::new()
626            .symbol("TST".to_string())
627            .name("Test Commodity".to_string())
628            .user_id(user.id)
629            .run()
630            .await?;
631
632        // Get the commodity ID
633        let _commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
634            uuid::Uuid::parse_str(&id)?
635        } else {
636            panic!("Expected commodity ID string result");
637        };
638
639        // Create an account
640        let account_name = "Test Account";
641        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
642            CreateAccount::new()
643                .name(account_name.to_string())
644                .user_id(user.id)
645                .run()
646                .await?
647        {
648            account
649        } else {
650            panic!("Expected account entity result");
651        };
652
653        // Test GetAccount by ID
654        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
655            .user_id(user.id)
656            .account_id(account.id)
657            .run()
658            .await?
659        {
660            assert_eq!(entities.len(), 1, "Expected one account");
661
662            let (entity, tags) = &entities[0];
663            if let FinanceEntity::Account(a) = entity {
664                assert_eq!(a.id, account.id);
665
666                // Check tags
667                assert_eq!(tags.len(), 1); // name tag
668                if let FinanceEntity::Tag(tag) = &tags["name"] {
669                    assert_eq!(tag.tag_name, "name");
670                    assert_eq!(tag.tag_value, account_name);
671                } else {
672                    panic!("Expected Tag entity");
673                }
674            } else {
675                panic!("Expected Account entity");
676            }
677        } else {
678            panic!("Expected TaggedEntities result");
679        }
680
681        // Test GetAccount by name
682        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
683            .user_id(user.id)
684            .account_name(account_name.to_string())
685            .run()
686            .await?
687        {
688            assert_eq!(entities.len(), 1, "Expected one account");
689
690            let (entity, _) = &entities[0];
691            if let FinanceEntity::Account(a) = entity {
692                assert_eq!(a.id, account.id);
693            } else {
694                panic!("Expected Account entity");
695            }
696        } else {
697            panic!("Expected TaggedEntities result");
698        }
699
700        // Test with non-existent account ID
701        let non_existent_id = Uuid::new_v4();
702        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
703            .user_id(user.id)
704            .account_id(non_existent_id)
705            .run()
706            .await?
707        {
708            assert_eq!(
709                entities.len(),
710                0,
711                "Expected no accounts for non-existent ID"
712            );
713        } else {
714            panic!("Expected empty TaggedEntities result");
715        }
716
717        // Test with non-existent account name
718        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
719            .user_id(user.id)
720            .account_name("Non-existent Account".to_string())
721            .run()
722            .await?
723        {
724            assert_eq!(
725                entities.len(),
726                0,
727                "Expected no accounts for non-existent name"
728            );
729        } else {
730            panic!("Expected empty TaggedEntities result");
731        }
732    }
733
734    #[local_db_sqlx_test]
735    async fn test_get_account_returns_parent(pool: PgPool) -> anyhow::Result<()> {
736        let user = USER.get().unwrap();
737        user.commit()
738            .await
739            .expect("Failed to commit user to database");
740
741        let parent = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
742            CreateAccount::new()
743                .name("Parent".to_string())
744                .user_id(user.id)
745                .run()
746                .await?
747        {
748            account
749        } else {
750            panic!("Expected account entity result");
751        };
752
753        let child = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
754            CreateAccount::new()
755                .name("Child".to_string())
756                .user_id(user.id)
757                .parent(parent.id)
758                .run()
759                .await?
760        {
761            account
762        } else {
763            panic!("Expected account entity result");
764        };
765
766        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetAccount::new()
767            .user_id(user.id)
768            .account_id(child.id)
769            .run()
770            .await?
771        {
772            let (entity, _) = &entities[0];
773            if let FinanceEntity::Account(a) = entity {
774                assert_eq!(
775                    a.parent,
776                    Some(parent.id),
777                    "get-account must return the child's parent"
778                );
779            } else {
780                panic!("Expected Account entity");
781            }
782        } else {
783            panic!("Expected TaggedEntities result");
784        }
785    }
786
787    #[local_db_sqlx_test]
788    async fn test_get_account_commodities_no_transactions(pool: PgPool) -> anyhow::Result<()> {
789        let user = USER.get().unwrap();
790        user.commit()
791            .await
792            .expect("Failed to commit user to database");
793
794        // Create a commodity
795        let _commodity_result = CreateCommodity::new()
796            .symbol("USD".to_string())
797            .name("US Dollar".to_string())
798            .user_id(user.id)
799            .run()
800            .await?;
801
802        // Create an account with no transactions
803        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
804            CreateAccount::new()
805                .name("Empty Account".to_string())
806                .user_id(user.id)
807                .run()
808                .await?
809        {
810            account
811        } else {
812            panic!("Expected account entity result");
813        };
814
815        // Test GetAccountCommodities on account with no transactions
816        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
817            .user_id(user.id)
818            .account_id(account.id)
819            .run()
820            .await?
821        {
822            assert_eq!(
823                commodities.len(),
824                0,
825                "Expected no commodities for account with no transactions"
826            );
827        } else {
828            panic!("Expected CommodityInfoList result");
829        }
830    }
831
832    #[local_db_sqlx_test]
833    async fn test_get_account_commodities_single_commodity(pool: PgPool) -> anyhow::Result<()> {
834        let user = USER.get().unwrap();
835        user.commit()
836            .await
837            .expect("Failed to commit user to database");
838
839        // Create a commodity
840        let commodity_result = CreateCommodity::new()
841            .symbol("EUR".to_string())
842            .name("Euro".to_string())
843            .user_id(user.id)
844            .run()
845            .await?;
846
847        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
848            uuid::Uuid::parse_str(&id)?
849        } else {
850            panic!("Expected commodity ID string result");
851        };
852
853        // Create two accounts
854        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
855            CreateAccount::new()
856                .name("Account 1".to_string())
857                .user_id(user.id)
858                .run()
859                .await?
860        {
861            account
862        } else {
863            panic!("Expected account entity result");
864        };
865
866        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
867            CreateAccount::new()
868                .name("Account 2".to_string())
869                .user_id(user.id)
870                .run()
871                .await?
872        {
873            account
874        } else {
875            panic!("Expected account entity result");
876        };
877
878        // Create a transaction with single commodity (EUR)
879        let tx_id = Uuid::new_v4();
880        let now = Utc::now();
881
882        let split1 = Split {
883            id: Uuid::new_v4(),
884            tx_id,
885            account_id: account1.id,
886            commodity_id,
887            value_num: -500,
888            value_denom: 1,
889            reconcile_state: None,
890            reconcile_date: None,
891            lot_id: None,
892        };
893
894        let split2 = Split {
895            id: Uuid::new_v4(),
896            tx_id,
897            account_id: account2.id,
898            commodity_id,
899            value_num: 500,
900            value_denom: 1,
901            reconcile_state: None,
902            reconcile_date: None,
903            lot_id: None,
904        };
905
906        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
907        CreateTransaction::new()
908            .user_id(user.id)
909            .splits(splits)
910            .id(tx_id)
911            .post_date(now)
912            .enter_date(now)
913            .run()
914            .await?;
915
916        // Test GetAccountCommodities on account1 (should have one commodity)
917        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
918            .user_id(user.id)
919            .account_id(account1.id)
920            .run()
921            .await?
922        {
923            assert_eq!(
924                commodities.len(),
925                1,
926                "Expected one commodity for account with single currency"
927            );
928
929            let commodity_info = &commodities[0];
930            assert_eq!(commodity_info.commodity_id, commodity_id);
931            assert_eq!(commodity_info.symbol, "EUR");
932            assert_eq!(commodity_info.name, "Euro");
933        } else {
934            panic!("Expected CommodityInfoList result");
935        }
936
937        // Test GetAccountCommodities on account2 (should also have one commodity)
938        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
939            .user_id(user.id)
940            .account_id(account2.id)
941            .run()
942            .await?
943        {
944            assert_eq!(
945                commodities.len(),
946                1,
947                "Expected one commodity for account with single currency"
948            );
949
950            let commodity_info = &commodities[0];
951            assert_eq!(commodity_info.commodity_id, commodity_id);
952            assert_eq!(commodity_info.symbol, "EUR");
953            assert_eq!(commodity_info.name, "Euro");
954        } else {
955            panic!("Expected CommodityInfoList result");
956        }
957    }
958
959    #[local_db_sqlx_test]
960    async fn test_get_account_commodities_multiple_commodities(pool: PgPool) -> anyhow::Result<()> {
961        let user = USER.get().unwrap();
962        user.commit()
963            .await
964            .expect("Failed to commit user to database");
965
966        // Create two commodities
967        let usd_result = CreateCommodity::new()
968            .symbol("USD".to_string())
969            .name("US Dollar".to_string())
970            .user_id(user.id)
971            .run()
972            .await?;
973
974        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
975            uuid::Uuid::parse_str(&id)?
976        } else {
977            panic!("Expected commodity ID string result");
978        };
979
980        let eur_result = CreateCommodity::new()
981            .symbol("EUR".to_string())
982            .name("Euro".to_string())
983            .user_id(user.id)
984            .run()
985            .await?;
986
987        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
988            uuid::Uuid::parse_str(&id)?
989        } else {
990            panic!("Expected commodity ID string result");
991        };
992
993        // Create accounts
994        let mixed_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
995            CreateAccount::new()
996                .name("Mixed Currency Account".to_string())
997                .user_id(user.id)
998                .run()
999                .await?
1000        {
1001            account
1002        } else {
1003            panic!("Expected account entity result");
1004        };
1005
1006        let other_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1007            CreateAccount::new()
1008                .name("Other Account".to_string())
1009                .user_id(user.id)
1010                .run()
1011                .await?
1012        {
1013            account
1014        } else {
1015            panic!("Expected account entity result");
1016        };
1017
1018        // Create first transaction with USD
1019        let tx1_id = Uuid::new_v4();
1020        let now = Utc::now();
1021
1022        let splits1 = vec![
1023            FinanceEntity::Split(Split {
1024                id: Uuid::new_v4(),
1025                tx_id: tx1_id,
1026                account_id: mixed_account.id,
1027                commodity_id: usd_id,
1028                value_num: 100,
1029                value_denom: 1,
1030                reconcile_state: None,
1031                reconcile_date: None,
1032                lot_id: None,
1033            }),
1034            FinanceEntity::Split(Split {
1035                id: Uuid::new_v4(),
1036                tx_id: tx1_id,
1037                account_id: other_account.id,
1038                commodity_id: usd_id,
1039                value_num: -100,
1040                value_denom: 1,
1041                reconcile_state: None,
1042                reconcile_date: None,
1043                lot_id: None,
1044            }),
1045        ];
1046
1047        CreateTransaction::new()
1048            .user_id(user.id)
1049            .splits(splits1)
1050            .id(tx1_id)
1051            .post_date(now)
1052            .enter_date(now)
1053            .run()
1054            .await?;
1055
1056        // Create second transaction with EUR
1057        let tx2_id = Uuid::new_v4();
1058
1059        let splits2 = vec![
1060            FinanceEntity::Split(Split {
1061                id: Uuid::new_v4(),
1062                tx_id: tx2_id,
1063                account_id: mixed_account.id,
1064                commodity_id: eur_id,
1065                value_num: 200,
1066                value_denom: 1,
1067                reconcile_state: None,
1068                reconcile_date: None,
1069                lot_id: None,
1070            }),
1071            FinanceEntity::Split(Split {
1072                id: Uuid::new_v4(),
1073                tx_id: tx2_id,
1074                account_id: other_account.id,
1075                commodity_id: eur_id,
1076                value_num: -200,
1077                value_denom: 1,
1078                reconcile_state: None,
1079                reconcile_date: None,
1080                lot_id: None,
1081            }),
1082        ];
1083
1084        CreateTransaction::new()
1085            .user_id(user.id)
1086            .splits(splits2)
1087            .id(tx2_id)
1088            .post_date(now)
1089            .enter_date(now)
1090            .run()
1091            .await?;
1092
1093        // Test GetAccountCommodities on mixed_account (should have two commodities)
1094        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
1095            .user_id(user.id)
1096            .account_id(mixed_account.id)
1097            .run()
1098            .await?
1099        {
1100            assert_eq!(
1101                commodities.len(),
1102                2,
1103                "Expected two commodities for account with mixed currencies"
1104            );
1105
1106            // Should be sorted by symbol (EUR comes before USD alphabetically)
1107            let eur_info = &commodities[0];
1108            assert_eq!(eur_info.commodity_id, eur_id);
1109            assert_eq!(eur_info.symbol, "EUR");
1110            assert_eq!(eur_info.name, "Euro");
1111
1112            let usd_info = &commodities[1];
1113            assert_eq!(usd_info.commodity_id, usd_id);
1114            assert_eq!(usd_info.symbol, "USD");
1115            assert_eq!(usd_info.name, "US Dollar");
1116        } else {
1117            panic!("Expected CommodityInfoList result");
1118        }
1119
1120        // Test GetAccountCommodities on other_account (should also have two commodities)
1121        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
1122            .user_id(user.id)
1123            .account_id(other_account.id)
1124            .run()
1125            .await?
1126        {
1127            assert_eq!(
1128                commodities.len(),
1129                2,
1130                "Expected two commodities for account with mixed currencies"
1131            );
1132        } else {
1133            panic!("Expected CommodityInfoList result");
1134        }
1135    }
1136
1137    #[local_db_sqlx_test]
1138    async fn test_get_account_commodities_error_cases(pool: PgPool) -> anyhow::Result<()> {
1139        let user = USER.get().unwrap();
1140        user.commit()
1141            .await
1142            .expect("Failed to commit user to database");
1143
1144        // Test with non-existent account ID
1145        let non_existent_account_id = Uuid::new_v4();
1146        if let Some(CmdResult::CommodityInfoList(commodities)) = GetAccountCommodities::new()
1147            .user_id(user.id)
1148            .account_id(non_existent_account_id)
1149            .run()
1150            .await?
1151        {
1152            assert_eq!(
1153                commodities.len(),
1154                0,
1155                "Expected no commodities for non-existent account"
1156            );
1157        } else {
1158            panic!("Expected CommodityInfoList result");
1159        }
1160
1161        // Test with non-existent user ID
1162        let non_existent_user_id = Uuid::new_v4();
1163        let result = GetAccountCommodities::new()
1164            .user_id(non_existent_user_id)
1165            .account_id(Uuid::new_v4())
1166            .run()
1167            .await;
1168
1169        // Should succeed but return empty list since user isolation prevents access
1170        if let Ok(Some(CmdResult::CommodityInfoList(commodities))) = result {
1171            assert_eq!(
1172                commodities.len(),
1173                0,
1174                "Expected no commodities for non-existent user"
1175            );
1176        } else {
1177            // Or it might fail due to connection issues, which is also acceptable
1178            assert!(
1179                result.is_err(),
1180                "Expected error or empty result for non-existent user"
1181            );
1182        }
1183    }
1184
1185    #[local_db_sqlx_test]
1186    async fn test_multi_currency_account_balance_with_price_conversion(pool: PgPool) {
1187        setup().await;
1188        let user = USER.get().unwrap();
1189        user.commit()
1190            .await
1191            .expect("Failed to commit user to database");
1192
1193        // Step 1: Create two commodities (USD and EUR)
1194        let usd_result = CreateCommodity::new()
1195            .symbol("USD".to_string())
1196            .name("US Dollar".to_string())
1197            .user_id(user.id)
1198            .run()
1199            .await
1200            .unwrap();
1201
1202        let eur_result = CreateCommodity::new()
1203            .symbol("EUR".to_string())
1204            .name("Euro".to_string())
1205            .user_id(user.id)
1206            .run()
1207            .await
1208            .unwrap();
1209
1210        // Extract commodity IDs
1211        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
1212            Uuid::parse_str(&id).unwrap()
1213        } else {
1214            panic!("Expected USD commodity ID");
1215        };
1216
1217        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
1218            Uuid::parse_str(&id).unwrap()
1219        } else {
1220            panic!("Expected EUR commodity ID");
1221        };
1222
1223        // Step 2: Create two accounts
1224        let account1_result = CreateAccount::new()
1225            .name("USD Account".to_string())
1226            .user_id(user.id)
1227            .run()
1228            .await
1229            .unwrap();
1230
1231        let account2_result = CreateAccount::new()
1232            .name("EUR Account".to_string())
1233            .user_id(user.id)
1234            .run()
1235            .await
1236            .unwrap();
1237
1238        // Extract account IDs
1239        let account1_id =
1240            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account1_result {
1241                acc.id
1242            } else {
1243                panic!("Expected USD account");
1244            };
1245
1246        let account2_id =
1247            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account2_result {
1248                acc.id
1249            } else {
1250                panic!("Expected EUR account");
1251            };
1252
1253        // Step 3: Create single-currency transactions first to test basic functionality
1254        let tx1_id = Uuid::new_v4();
1255        let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap(); // Fixed timestamp
1256
1257        let tx1_split1 = Split {
1258            id: Uuid::new_v4(),
1259            tx_id: tx1_id,
1260            account_id: account1_id,
1261            commodity_id: usd_id,
1262            value_num: -100,
1263            value_denom: 1,
1264            reconcile_state: None,
1265            reconcile_date: None,
1266            lot_id: None,
1267        };
1268
1269        let tx1_split2 = Split {
1270            id: Uuid::new_v4(),
1271            tx_id: tx1_id,
1272            account_id: account2_id,
1273            commodity_id: usd_id, // Same currency to make transaction balance
1274            value_num: 100,
1275            value_denom: 1,
1276            reconcile_state: None,
1277            reconcile_date: None,
1278            lot_id: None,
1279        };
1280
1281        let tx1_splits = vec![
1282            FinanceEntity::Split(tx1_split1),
1283            FinanceEntity::Split(tx1_split2),
1284        ];
1285
1286        CreateTransaction::new()
1287            .user_id(user.id)
1288            .splits(tx1_splits)
1289            .id(tx1_id)
1290            .post_date(now)
1291            .enter_date(now)
1292            .note("First USD transaction".to_string())
1293            .run()
1294            .await
1295            .unwrap();
1296
1297        // Create second transaction in EUR currency
1298        let tx2_id = Uuid::new_v4();
1299        let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); // 10 minutes later
1300
1301        let tx2_split1 = Split {
1302            id: Uuid::new_v4(),
1303            tx_id: tx2_id,
1304            account_id: account1_id,
1305            commodity_id: eur_id,
1306            value_num: -85,
1307            value_denom: 1,
1308            reconcile_state: None,
1309            reconcile_date: None,
1310            lot_id: None,
1311        };
1312
1313        let tx2_split2 = Split {
1314            id: Uuid::new_v4(),
1315            tx_id: tx2_id,
1316            account_id: account2_id,
1317            commodity_id: eur_id,
1318            value_num: 85,
1319            value_denom: 1,
1320            reconcile_state: None,
1321            reconcile_date: None,
1322            lot_id: None,
1323        };
1324
1325        let tx2_splits = vec![
1326            FinanceEntity::Split(tx2_split1),
1327            FinanceEntity::Split(tx2_split2),
1328        ];
1329
1330        CreateTransaction::new()
1331            .user_id(user.id)
1332            .splits(tx2_splits)
1333            .id(tx2_id)
1334            .post_date(later)
1335            .enter_date(later)
1336            .note("Second EUR transaction".to_string())
1337            .run()
1338            .await
1339            .unwrap();
1340
1341        // Create price conversion data between EUR and USD
1342        let price1 = Price {
1343            id: Uuid::new_v4(),
1344            date: now,
1345            commodity_id: eur_id,
1346            currency_id: usd_id,
1347            commodity_split: None, // General price, not tied to specific splits
1348            currency_split: None,
1349            value_num: 1176, // 1.176 USD per EUR (as rational: 1176/1000)
1350            value_denom: 1000,
1351        };
1352
1353        // Insert price manually using raw SQL since we don't have a CreatePrice command
1354        let mut conn = user.get_connection().await.unwrap();
1355        sqlx::query_file!(
1356            "sql/insert/prices/price.sql",
1357            price1.id,
1358            price1.commodity_id,
1359            price1.currency_id,
1360            price1.commodity_split,
1361            price1.currency_split,
1362            price1.date,
1363            price1.value_num,
1364            price1.value_denom
1365        )
1366        .execute(&mut *conn)
1367        .await
1368        .unwrap();
1369
1370        // Test Account1 balance (mixed currencies - should return MultiCurrencyBalance without commodity_id)
1371        let balance_result1 = GetBalance::new()
1372            .user_id(user.id)
1373            .account_id(account1_id)
1374            .run()
1375            .await
1376            .unwrap();
1377
1378        // Should return MultiCurrencyBalance due to mixed currencies
1379        match balance_result1 {
1380            Some(CmdResult::MultiCurrencyBalance(balances)) => {
1381                assert_eq!(
1382                    balances.len(),
1383                    2,
1384                    "Account1 should have two currency balances"
1385                );
1386            }
1387            _ => panic!("Expected MultiCurrencyBalance result for account1"),
1388        }
1389
1390        // Test Account1 balance in USD (should fail due to missing split-specific conversion)
1391        let balance_result2 = GetBalance::new()
1392            .user_id(user.id)
1393            .account_id(account1_id)
1394            .commodity_id(usd_id)
1395            .run()
1396            .await;
1397
1398        // Should fail because the price record is not split-specific
1399        assert!(
1400            balance_result2.is_err(),
1401            "Expected error for missing split-specific EUR->USD conversion"
1402        );
1403
1404        // Verify it's the right kind of error
1405        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
1406            from_commodity,
1407            to_commodity,
1408            ..
1409        }))) = balance_result2
1410        {
1411            assert_eq!(from_commodity, "EUR");
1412            assert_eq!(to_commodity, "USD");
1413        } else {
1414            panic!("Expected MissingConversion error");
1415        }
1416
1417        // Test Account2 balance (mixed currencies - should return MultiCurrencyBalance without commodity_id)
1418        let balance_result3 = GetBalance::new()
1419            .user_id(user.id)
1420            .account_id(account2_id)
1421            .run()
1422            .await
1423            .unwrap();
1424
1425        // Should return MultiCurrencyBalance due to mixed currencies
1426        match balance_result3 {
1427            Some(CmdResult::MultiCurrencyBalance(balances)) => {
1428                assert_eq!(
1429                    balances.len(),
1430                    2,
1431                    "Account2 should have two currency balances"
1432                );
1433            }
1434            _ => panic!("Expected MultiCurrencyBalance result for account2"),
1435        }
1436
1437        // Test Account2 balance in EUR (should fail due to missing split-specific conversion)
1438        let balance_result4 = GetBalance::new()
1439            .user_id(user.id)
1440            .account_id(account2_id)
1441            .commodity_id(eur_id)
1442            .run()
1443            .await;
1444
1445        // Should fail because USD->EUR conversion is not available (split-specific)
1446        assert!(
1447            balance_result4.is_err(),
1448            "Expected error for missing split-specific USD->EUR conversion"
1449        );
1450
1451        // Verify it's the right kind of error
1452        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
1453            from_commodity,
1454            to_commodity,
1455            ..
1456        }))) = balance_result4
1457        {
1458            assert_eq!(from_commodity, "USD");
1459            assert_eq!(to_commodity, "EUR");
1460        } else {
1461            panic!("Expected MissingConversion error");
1462        }
1463
1464        // Test currency conversion - get Account2 balance in USD (should fail due to missing split-specific conversion)
1465        let balance_result5 = GetBalance::new()
1466            .user_id(user.id)
1467            .account_id(account2_id)
1468            .commodity_id(usd_id) // Convert to USD
1469            .run()
1470            .await;
1471
1472        // Should fail because EUR->USD conversion is not available (split-specific)
1473        assert!(
1474            balance_result5.is_err(),
1475            "Expected error for missing split-specific EUR->USD conversion"
1476        );
1477
1478        // Verify it's the right kind of error
1479        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
1480            from_commodity,
1481            to_commodity,
1482            ..
1483        }))) = balance_result5
1484        {
1485            assert_eq!(from_commodity, "EUR");
1486            assert_eq!(to_commodity, "USD");
1487        } else {
1488            panic!("Expected MissingConversion error for USD conversion");
1489        }
1490    }
1491
1492    #[local_db_sqlx_test]
1493    async fn test_account_balance_without_price_data(pool: PgPool) {
1494        setup().await;
1495        let user = USER.get().unwrap();
1496        user.commit()
1497            .await
1498            .expect("Failed to commit user to database");
1499
1500        // Create commodities and accounts
1501        let usd_result = CreateCommodity::new()
1502            .symbol("USD".to_string())
1503            .name("US Dollar".to_string())
1504            .user_id(user.id)
1505            .run()
1506            .await
1507            .unwrap();
1508
1509        let eur_result = CreateCommodity::new()
1510            .symbol("EUR".to_string())
1511            .name("Euro".to_string())
1512            .user_id(user.id)
1513            .run()
1514            .await
1515            .unwrap();
1516
1517        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
1518            Uuid::parse_str(&id).unwrap()
1519        } else {
1520            panic!("Expected USD commodity ID");
1521        };
1522
1523        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
1524            Uuid::parse_str(&id).unwrap()
1525        } else {
1526            panic!("Expected EUR commodity ID");
1527        };
1528
1529        let account_result = CreateAccount::new()
1530            .name("Mixed Account".to_string())
1531            .user_id(user.id)
1532            .run()
1533            .await
1534            .unwrap();
1535
1536        let account_id =
1537            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account_result {
1538                acc.id
1539            } else {
1540                panic!("Expected account");
1541            };
1542
1543        // Create a second account to make balanced transactions
1544        let account2_result = CreateAccount::new()
1545            .name("Second Account".to_string())
1546            .user_id(user.id)
1547            .run()
1548            .await
1549            .unwrap();
1550
1551        let account2_id =
1552            if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = account2_result {
1553                acc.id
1554            } else {
1555                panic!("Expected second account");
1556            };
1557
1558        // Create first transaction (USD) WITHOUT price data
1559        let tx1_id = Uuid::new_v4();
1560        let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap();
1561
1562        let split1 = Split {
1563            id: Uuid::new_v4(),
1564            tx_id: tx1_id,
1565            account_id,
1566            commodity_id: usd_id,
1567            value_num: 100,
1568            value_denom: 1,
1569            reconcile_state: None,
1570            reconcile_date: None,
1571            lot_id: None,
1572        };
1573
1574        let split1_balance = Split {
1575            id: Uuid::new_v4(),
1576            tx_id: tx1_id,
1577            account_id: account2_id,
1578            commodity_id: usd_id,
1579            value_num: -100,
1580            value_denom: 1,
1581            reconcile_state: None,
1582            reconcile_date: None,
1583            lot_id: None,
1584        };
1585
1586        let splits1 = vec![
1587            FinanceEntity::Split(split1),
1588            FinanceEntity::Split(split1_balance),
1589        ];
1590        CreateTransaction::new()
1591            .user_id(user.id)
1592            .splits(splits1)
1593            .id(tx1_id)
1594            .post_date(now)
1595            .enter_date(now)
1596            .note("USD transaction without price data".to_string())
1597            .run()
1598            .await
1599            .unwrap();
1600
1601        // Create second transaction (EUR) WITHOUT price data
1602        let tx2_id = Uuid::new_v4();
1603
1604        let split2 = Split {
1605            id: Uuid::new_v4(),
1606            tx_id: tx2_id,
1607            account_id,
1608            commodity_id: eur_id,
1609            value_num: -85,
1610            value_denom: 1,
1611            reconcile_state: None,
1612            reconcile_date: None,
1613            lot_id: None,
1614        };
1615
1616        let split2_balance = Split {
1617            id: Uuid::new_v4(),
1618            tx_id: tx2_id,
1619            account_id: account2_id,
1620            commodity_id: eur_id,
1621            value_num: 85,
1622            value_denom: 1,
1623            reconcile_state: None,
1624            reconcile_date: None,
1625            lot_id: None,
1626        };
1627
1628        let splits2 = vec![
1629            FinanceEntity::Split(split2),
1630            FinanceEntity::Split(split2_balance),
1631        ];
1632        CreateTransaction::new()
1633            .user_id(user.id)
1634            .splits(splits2)
1635            .id(tx2_id)
1636            .post_date(now)
1637            .enter_date(now)
1638            .note("EUR transaction without price data".to_string())
1639            .run()
1640            .await
1641            .unwrap();
1642
1643        // Test balance calculation - should fail due to missing conversion
1644        let balance_result = GetBalance::new()
1645            .user_id(user.id)
1646            .account_id(account_id)
1647            .commodity_id(usd_id) // Try to get balance in USD
1648            .run()
1649            .await;
1650
1651        // Should fail due to missing EUR->USD price conversion
1652        assert!(
1653            balance_result.is_err(),
1654            "Expected error for missing EUR->USD conversion"
1655        );
1656
1657        // Verify it's the right kind of error
1658        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
1659            from_commodity,
1660            to_commodity,
1661            ..
1662        }))) = balance_result
1663        {
1664            assert_eq!(from_commodity, "EUR");
1665            assert_eq!(to_commodity, "USD");
1666        } else {
1667            panic!("Expected MissingConversion error");
1668        }
1669    }
1670
1671    #[local_db_sqlx_test]
1672    async fn test_get_balance_single_currency(pool: PgPool) {
1673        setup().await;
1674        let user = USER.get().unwrap();
1675        user.commit()
1676            .await
1677            .expect("Failed to commit user to database");
1678
1679        // Create a commodity
1680        let commodity_result = CreateCommodity::new()
1681            .symbol("USD".to_string())
1682            .name("US Dollar".to_string())
1683            .user_id(user.id)
1684            .run()
1685            .await
1686            .expect("Failed to create commodity");
1687
1688        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1689            Uuid::parse_str(&id).expect("Failed to parse commodity ID")
1690        } else {
1691            panic!("Expected commodity ID string result");
1692        };
1693
1694        // Create two accounts
1695        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1696            CreateAccount::new()
1697                .name("Test Account 1".to_string())
1698                .user_id(user.id)
1699                .run()
1700                .await
1701                .expect("Test operation failed")
1702        {
1703            account
1704        } else {
1705            panic!("Expected account entity result");
1706        };
1707
1708        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1709            CreateAccount::new()
1710                .name("Test Account 2".to_string())
1711                .user_id(user.id)
1712                .run()
1713                .await
1714                .expect("Test operation failed")
1715        {
1716            account
1717        } else {
1718            panic!("Expected account entity result");
1719        };
1720
1721        // Create a transaction: 100 USD from account1 to account2
1722        let tx_id = Uuid::new_v4();
1723        let now = Utc::now();
1724
1725        let split1 = Split {
1726            id: Uuid::new_v4(),
1727            tx_id,
1728            account_id: account1.id,
1729            commodity_id,
1730            value_num: -100,
1731            value_denom: 1,
1732            reconcile_state: None,
1733            reconcile_date: None,
1734            lot_id: None,
1735        };
1736
1737        let split2 = Split {
1738            id: Uuid::new_v4(),
1739            tx_id,
1740            account_id: account2.id,
1741            commodity_id,
1742            value_num: 100,
1743            value_denom: 1,
1744            reconcile_state: None,
1745            reconcile_date: None,
1746            lot_id: None,
1747        };
1748
1749        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1750        CreateTransaction::new()
1751            .user_id(user.id)
1752            .splits(splits)
1753            .id(tx_id)
1754            .post_date(now)
1755            .enter_date(now)
1756            .run()
1757            .await
1758            .expect("Test operation failed");
1759
1760        // Test balance for account1 (should be -100)
1761        let balance_result1 = GetBalance::new()
1762            .user_id(user.id)
1763            .account_id(account1.id)
1764            .run()
1765            .await
1766            .expect("Test operation failed");
1767
1768        if let Some(CmdResult::Rational(balance)) = balance_result1 {
1769            assert_eq!(
1770                balance,
1771                Rational64::new(-100, 1),
1772                "Account1 balance should be -100, got: {balance}"
1773            );
1774        } else {
1775            panic!("Expected rational balance result for account1");
1776        }
1777
1778        // Test balance for account2 (should be 100)
1779        let balance_result2 = GetBalance::new()
1780            .user_id(user.id)
1781            .account_id(account2.id)
1782            .run()
1783            .await
1784            .expect("Test operation failed");
1785
1786        if let Some(CmdResult::Rational(balance)) = balance_result2 {
1787            assert_eq!(
1788                balance,
1789                Rational64::new(100, 1),
1790                "Account2 balance should be 100, got: {balance}"
1791            );
1792        } else {
1793            panic!("Expected rational balance result for account2");
1794        }
1795
1796        // Test balance with explicit commodity_id (should work the same)
1797        let balance_result3 = GetBalance::new()
1798            .user_id(user.id)
1799            .account_id(account1.id)
1800            .commodity_id(commodity_id)
1801            .run()
1802            .await
1803            .expect("Test operation failed");
1804
1805        if let Some(CmdResult::Rational(balance)) = balance_result3 {
1806            assert_eq!(
1807                balance,
1808                Rational64::new(-100, 1),
1809                "Account1 balance with explicit commodity_id should be -100, got: {balance}"
1810            );
1811        } else {
1812            panic!("Expected rational balance result with explicit commodity_id");
1813        }
1814    }
1815
1816    #[local_db_sqlx_test]
1817    async fn test_get_balance_empty_account(pool: PgPool) {
1818        setup().await;
1819        let user = USER.get().unwrap();
1820        user.commit()
1821            .await
1822            .expect("Failed to commit user to database");
1823
1824        // Create an account with no transactions
1825        let account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1826            CreateAccount::new()
1827                .name("Empty Account".to_string())
1828                .user_id(user.id)
1829                .run()
1830                .await
1831                .expect("Test operation failed")
1832        {
1833            account
1834        } else {
1835            panic!("Expected account entity result");
1836        };
1837
1838        // Test balance for empty account (should be 0)
1839        let balance_result = GetBalance::new()
1840            .user_id(user.id)
1841            .account_id(account.id)
1842            .run()
1843            .await
1844            .expect("Test operation failed");
1845
1846        if let Some(CmdResult::Rational(balance)) = balance_result {
1847            assert_eq!(
1848                balance,
1849                Rational64::new(0, 1),
1850                "Empty account balance should be 0, got: {balance}"
1851            );
1852        } else {
1853            panic!("Expected rational balance result for empty account");
1854        }
1855    }
1856
1857    #[local_db_sqlx_test]
1858    async fn test_get_balance_mixed_currencies_error(pool: PgPool) {
1859        setup().await;
1860        let user = USER.get().unwrap();
1861        user.commit()
1862            .await
1863            .expect("Failed to commit user to database");
1864
1865        // Create two commodities
1866        let usd_result = CreateCommodity::new()
1867            .symbol("USD".to_string())
1868            .name("US Dollar".to_string())
1869            .user_id(user.id)
1870            .run()
1871            .await
1872            .expect("Test operation failed");
1873
1874        let eur_result = CreateCommodity::new()
1875            .symbol("EUR".to_string())
1876            .name("Euro".to_string())
1877            .user_id(user.id)
1878            .run()
1879            .await
1880            .expect("Test operation failed");
1881
1882        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
1883            Uuid::parse_str(&id).expect("Failed to parse USD commodity ID")
1884        } else {
1885            panic!("Expected USD commodity ID");
1886        };
1887
1888        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
1889            Uuid::parse_str(&id).expect("Failed to parse EUR commodity ID")
1890        } else {
1891            panic!("Expected EUR commodity ID");
1892        };
1893
1894        // Create three accounts
1895        let mixed_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1896            CreateAccount::new()
1897                .name("Mixed Currency Account".to_string())
1898                .user_id(user.id)
1899                .run()
1900                .await
1901                .expect("Test operation failed")
1902        {
1903            account
1904        } else {
1905            panic!("Expected mixed account entity result");
1906        };
1907
1908        let usd_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1909            CreateAccount::new()
1910                .name("USD Account".to_string())
1911                .user_id(user.id)
1912                .run()
1913                .await
1914                .expect("Test operation failed")
1915        {
1916            account
1917        } else {
1918            panic!("Expected USD account entity result");
1919        };
1920
1921        let eur_account = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1922            CreateAccount::new()
1923                .name("EUR Account".to_string())
1924                .user_id(user.id)
1925                .run()
1926                .await
1927                .expect("Test operation failed")
1928        {
1929            account
1930        } else {
1931            panic!("Expected EUR account entity result");
1932        };
1933
1934        // Create first transaction: 100 USD to mixed_account
1935        let tx1_id = Uuid::new_v4();
1936        let now = Utc::now();
1937
1938        let splits1 = vec![
1939            FinanceEntity::Split(Split {
1940                id: Uuid::new_v4(),
1941                tx_id: tx1_id,
1942                account_id: mixed_account.id,
1943                commodity_id: usd_id,
1944                value_num: 100,
1945                value_denom: 1,
1946                reconcile_state: None,
1947                reconcile_date: None,
1948                lot_id: None,
1949            }),
1950            FinanceEntity::Split(Split {
1951                id: Uuid::new_v4(),
1952                tx_id: tx1_id,
1953                account_id: usd_account.id,
1954                commodity_id: usd_id,
1955                value_num: -100,
1956                value_denom: 1,
1957                reconcile_state: None,
1958                reconcile_date: None,
1959                lot_id: None,
1960            }),
1961        ];
1962
1963        CreateTransaction::new()
1964            .user_id(user.id)
1965            .splits(splits1)
1966            .id(tx1_id)
1967            .post_date(now)
1968            .enter_date(now)
1969            .run()
1970            .await
1971            .expect("Test operation failed");
1972
1973        // Create second transaction: 50 EUR to mixed_account
1974        let tx2_id = Uuid::new_v4();
1975
1976        let splits2 = vec![
1977            FinanceEntity::Split(Split {
1978                id: Uuid::new_v4(),
1979                tx_id: tx2_id,
1980                account_id: mixed_account.id,
1981                commodity_id: eur_id,
1982                value_num: 50,
1983                value_denom: 1,
1984                reconcile_state: None,
1985                reconcile_date: None,
1986                lot_id: None,
1987            }),
1988            FinanceEntity::Split(Split {
1989                id: Uuid::new_v4(),
1990                tx_id: tx2_id,
1991                account_id: eur_account.id,
1992                commodity_id: eur_id,
1993                value_num: -50,
1994                value_denom: 1,
1995                reconcile_state: None,
1996                reconcile_date: None,
1997                lot_id: None,
1998            }),
1999        ];
2000
2001        CreateTransaction::new()
2002            .user_id(user.id)
2003            .splits(splits2)
2004            .id(tx2_id)
2005            .post_date(now)
2006            .enter_date(now)
2007            .run()
2008            .await
2009            .expect("Test operation failed");
2010
2011        // Test balance for mixed_account without commodity_id (should return MultiCurrencyBalance)
2012        let balance_result = GetBalance::new()
2013            .user_id(user.id)
2014            .account_id(mixed_account.id)
2015            .run()
2016            .await
2017            .expect("Test operation failed");
2018
2019        if let Some(CmdResult::MultiCurrencyBalance(balances)) = balance_result {
2020            assert_eq!(balances.len(), 2, "Expected two currency balances");
2021            // USD balance should be 100, EUR balance should be 50
2022            for (commodity, balance) in balances {
2023                match balance.to_integer() {
2024                    100 => {
2025                        // USD balance
2026                        assert_eq!(commodity.id, usd_id);
2027                    }
2028                    50 => {
2029                        // EUR balance
2030                        assert_eq!(commodity.id, eur_id);
2031                    }
2032                    _ => panic!("Unexpected balance: {balance}"),
2033                }
2034            }
2035        } else {
2036            panic!("Expected MultiCurrencyBalance result for mixed currencies");
2037        }
2038
2039        // Test balance with specific commodity_id (should error due to missing conversion)
2040        let balance_result_usd = GetBalance::new()
2041            .user_id(user.id)
2042            .account_id(mixed_account.id)
2043            .commodity_id(usd_id)
2044            .run()
2045            .await;
2046
2047        // Should fail due to missing EUR->USD price conversion
2048        assert!(
2049            balance_result_usd.is_err(),
2050            "Expected error for missing EUR->USD conversion"
2051        );
2052
2053        // Verify it's the right kind of error
2054        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
2055            from_commodity,
2056            to_commodity,
2057            ..
2058        }))) = balance_result_usd
2059        {
2060            assert_eq!(from_commodity, "EUR");
2061            assert_eq!(to_commodity, "USD");
2062        } else {
2063            panic!("Expected MissingConversion error");
2064        }
2065    }
2066
2067    #[local_db_sqlx_test]
2068    async fn test_cross_account_balance_isolation(pool: PgPool) {
2069        setup().await;
2070        let user = USER.get().unwrap();
2071        user.commit()
2072            .await
2073            .expect("Failed to commit user to database");
2074
2075        // Create commodities
2076        let usd_result = CreateCommodity::new()
2077            .symbol("USD".to_string())
2078            .name("US Dollar".to_string())
2079            .user_id(user.id)
2080            .run()
2081            .await
2082            .unwrap();
2083
2084        let eur_result = CreateCommodity::new()
2085            .symbol("EUR".to_string())
2086            .name("Euro".to_string())
2087            .user_id(user.id)
2088            .run()
2089            .await
2090            .unwrap();
2091
2092        let usd_id = if let Some(CmdResult::String(id)) = usd_result {
2093            Uuid::parse_str(&id).unwrap()
2094        } else {
2095            panic!("Expected USD commodity ID");
2096        };
2097
2098        let eur_id = if let Some(CmdResult::String(id)) = eur_result {
2099            Uuid::parse_str(&id).unwrap()
2100        } else {
2101            panic!("Expected EUR commodity ID");
2102        };
2103
2104        // Create two accounts
2105        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) =
2106            CreateAccount::new()
2107                .name("Account1".to_string())
2108                .user_id(user.id)
2109                .run()
2110                .await
2111                .unwrap()
2112        {
2113            acc
2114        } else {
2115            panic!("Expected account1");
2116        };
2117
2118        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) =
2119            CreateAccount::new()
2120                .name("Account2".to_string())
2121                .user_id(user.id)
2122                .run()
2123                .await
2124                .unwrap()
2125        {
2126            acc
2127        } else {
2128            panic!("Expected account2");
2129        };
2130
2131        // Transaction 1: 100 USD from account1 to account2
2132        let tx1_id = Uuid::new_v4();
2133        let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap();
2134
2135        let splits1 = vec![
2136            FinanceEntity::Split(Split {
2137                id: Uuid::new_v4(),
2138                tx_id: tx1_id,
2139                account_id: account1.id,
2140                commodity_id: usd_id,
2141                value_num: -100,
2142                value_denom: 1,
2143                reconcile_state: None,
2144                reconcile_date: None,
2145                lot_id: None,
2146            }),
2147            FinanceEntity::Split(Split {
2148                id: Uuid::new_v4(),
2149                tx_id: tx1_id,
2150                account_id: account2.id,
2151                commodity_id: usd_id,
2152                value_num: 100,
2153                value_denom: 1,
2154                reconcile_state: None,
2155                reconcile_date: None,
2156                lot_id: None,
2157            }),
2158        ];
2159
2160        CreateTransaction::new()
2161            .user_id(user.id)
2162            .splits(splits1)
2163            .id(tx1_id)
2164            .post_date(now)
2165            .enter_date(now)
2166            .run()
2167            .await
2168            .unwrap();
2169
2170        // Check initial balances (should be simple single-currency)
2171        let balance1_initial = GetBalance::new()
2172            .user_id(user.id)
2173            .account_id(account1.id)
2174            .run()
2175            .await
2176            .unwrap();
2177
2178        let balance2_initial = GetBalance::new()
2179            .user_id(user.id)
2180            .account_id(account2.id)
2181            .run()
2182            .await
2183            .unwrap();
2184
2185        // Both should have simple USD balances
2186        match balance1_initial {
2187            Some(CmdResult::Rational(balance)) => {
2188                assert_eq!(balance, Rational64::new(-100, 1));
2189            }
2190            _ => panic!("Expected Rational balance result for account1"),
2191        }
2192        match balance2_initial {
2193            Some(CmdResult::Rational(balance)) => {
2194                assert_eq!(balance, Rational64::new(100, 1));
2195            }
2196            _ => panic!("Expected Rational balance result for account2"),
2197        }
2198
2199        // Transaction 2: Add EUR transaction - 50 EUR from account1 to account2
2200        let tx2_id = Uuid::new_v4();
2201        let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); // 10 minutes later
2202
2203        let splits2 = vec![
2204            FinanceEntity::Split(Split {
2205                id: Uuid::new_v4(),
2206                tx_id: tx2_id,
2207                account_id: account1.id,
2208                commodity_id: eur_id,
2209                value_num: -50,
2210                value_denom: 1,
2211                reconcile_state: None,
2212                reconcile_date: None,
2213                lot_id: None,
2214            }),
2215            FinanceEntity::Split(Split {
2216                id: Uuid::new_v4(),
2217                tx_id: tx2_id,
2218                account_id: account2.id,
2219                commodity_id: eur_id,
2220                value_num: 50,
2221                value_denom: 1,
2222                reconcile_state: None,
2223                reconcile_date: None,
2224                lot_id: None,
2225            }),
2226        ];
2227
2228        CreateTransaction::new()
2229            .user_id(user.id)
2230            .splits(splits2)
2231            .id(tx2_id)
2232            .post_date(later)
2233            .enter_date(later)
2234            .run()
2235            .await
2236            .unwrap();
2237
2238        // Now both accounts have mixed currencies and should return MultiCurrencyBalance
2239        let balance1_after = GetBalance::new()
2240            .user_id(user.id)
2241            .account_id(account1.id)
2242            .run()
2243            .await
2244            .unwrap();
2245
2246        let balance2_after = GetBalance::new()
2247            .user_id(user.id)
2248            .account_id(account2.id)
2249            .run()
2250            .await
2251            .unwrap();
2252
2253        // Both should return MultiCurrencyBalance due to mixed currencies
2254        match balance1_after {
2255            Some(CmdResult::MultiCurrencyBalance(balances)) => {
2256                assert_eq!(
2257                    balances.len(),
2258                    2,
2259                    "Account1 should have two currency balances"
2260                );
2261            }
2262            _ => panic!("Expected MultiCurrencyBalance result for account1"),
2263        }
2264        match balance2_after {
2265            Some(CmdResult::MultiCurrencyBalance(balances)) => {
2266                assert_eq!(
2267                    balances.len(),
2268                    2,
2269                    "Account2 should have two currency balances"
2270                );
2271            }
2272            _ => panic!("Expected MultiCurrencyBalance result for account2"),
2273        }
2274
2275        // Test Account2 balance in EUR before adding account1-only transaction
2276        let balance2_eur_before = GetBalance::new()
2277            .user_id(user.id)
2278            .account_id(account2.id)
2279            .commodity_id(eur_id)
2280            .run()
2281            .await;
2282
2283        // Should fail due to missing USD->EUR price conversion
2284        assert!(
2285            balance2_eur_before.is_err(),
2286            "Expected error for missing USD->EUR conversion"
2287        );
2288
2289        // Verify it's the right kind of error
2290        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
2291            from_commodity,
2292            to_commodity,
2293            ..
2294        }))) = balance2_eur_before
2295        {
2296            assert_eq!(from_commodity, "USD");
2297            assert_eq!(to_commodity, "EUR");
2298        } else {
2299            panic!("Expected MissingConversion error");
2300        }
2301
2302        // Now add a third transaction affecting only account1 (not account2)
2303        let tx3_id = Uuid::new_v4();
2304        let even_later = DateTime::<Utc>::from_timestamp(1640996400, 0).unwrap(); // 20 minutes later
2305
2306        let account3 = if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) =
2307            CreateAccount::new()
2308                .name("Account3".to_string())
2309                .user_id(user.id)
2310                .run()
2311                .await
2312                .unwrap()
2313        {
2314            acc
2315        } else {
2316            panic!("Expected account3");
2317        };
2318
2319        let splits3 = vec![
2320            FinanceEntity::Split(Split {
2321                id: Uuid::new_v4(),
2322                tx_id: tx3_id,
2323                account_id: account1.id,
2324                commodity_id: usd_id,
2325                value_num: -25,
2326                value_denom: 1,
2327                reconcile_state: None,
2328                reconcile_date: None,
2329                lot_id: None,
2330            }),
2331            FinanceEntity::Split(Split {
2332                id: Uuid::new_v4(),
2333                tx_id: tx3_id,
2334                account_id: account3.id,
2335                commodity_id: usd_id,
2336                value_num: 25,
2337                value_denom: 1,
2338                reconcile_state: None,
2339                reconcile_date: None,
2340                lot_id: None,
2341            }),
2342        ];
2343
2344        CreateTransaction::new()
2345            .user_id(user.id)
2346            .splits(splits3)
2347            .id(tx3_id)
2348            .post_date(even_later)
2349            .enter_date(even_later)
2350            .run()
2351            .await
2352            .unwrap();
2353
2354        // CRITICAL TEST: Account2's balance should still fail due to missing USD->EUR conversion
2355        // This verifies that account1's new transaction doesn't affect account2's error condition
2356        let balance2_eur_after = GetBalance::new()
2357            .user_id(user.id)
2358            .account_id(account2.id)
2359            .commodity_id(eur_id)
2360            .run()
2361            .await;
2362
2363        // Should still fail due to missing USD->EUR price conversion, unchanged by account1's new transaction
2364        assert!(
2365            balance2_eur_after.is_err(),
2366            "Expected error for missing USD->EUR conversion (account isolation test)"
2367        );
2368
2369        // Verify it's still the same kind of error
2370        if let Err(CmdError::Finance(FinanceError::Balance(BalanceError::MissingConversion {
2371            from_commodity,
2372            to_commodity,
2373            ..
2374        }))) = balance2_eur_after
2375        {
2376            assert_eq!(from_commodity, "USD");
2377            assert_eq!(to_commodity, "EUR");
2378        } else {
2379            panic!("Expected MissingConversion error");
2380        }
2381    }
2382}