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 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::builder()
53 .id(row.id)
54 .build()
55 .expect("Account built with all required fields");
56
57 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 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 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 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 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 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 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 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 let rows = sqlx::query_file!("sql/select/accounts/commodities.sql", &account_id)
344 .fetch_all(&mut *conn)
345 .await?;
346
347 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 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 return Ok(Some(CmdResult::Rational(Rational64::new(0, 1))));
388 }
389
390 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 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 total_balance += split_value;
404 } else {
405 if let (Some(price_num), Some(price_denom)) = (split_data.price_num, split_data.price_denom) {
407 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 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 if unique_commodities.len() == 1 {
435 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 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 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 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 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 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 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 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 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 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 CreateAccount::new()
589 .name("Test Account".to_string())
590 .user_id(user.id)
591 .run()
592 .await?;
593
594 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 assert_eq!(tags.len(), 1); 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 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 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 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 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 assert_eq!(tags.len(), 1); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let tx1_id = Uuid::new_v4();
1203 let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap(); 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, 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 let tx2_id = Uuid::new_v4();
1247 let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); 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 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, currency_split: None,
1297 value_num: 1176, value_denom: 1000,
1299 };
1300
1301 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 let balance_result1 = GetBalance::new()
1320 .user_id(user.id)
1321 .account_id(account1_id)
1322 .run()
1323 .await
1324 .unwrap();
1325
1326 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 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 assert!(
1348 balance_result2.is_err(),
1349 "Expected error for missing split-specific EUR->USD conversion"
1350 );
1351
1352 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 let balance_result3 = GetBalance::new()
1367 .user_id(user.id)
1368 .account_id(account2_id)
1369 .run()
1370 .await
1371 .unwrap();
1372
1373 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 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 assert!(
1395 balance_result4.is_err(),
1396 "Expected error for missing split-specific USD->EUR conversion"
1397 );
1398
1399 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 let balance_result5 = GetBalance::new()
1414 .user_id(user.id)
1415 .account_id(account2_id)
1416 .commodity_id(usd_id) .run()
1418 .await;
1419
1420 assert!(
1422 balance_result5.is_err(),
1423 "Expected error for missing split-specific EUR->USD conversion"
1424 );
1425
1426 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 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 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 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 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 let balance_result = GetBalance::new()
1593 .user_id(user.id)
1594 .account_id(account_id)
1595 .commodity_id(usd_id) .run()
1597 .await;
1598
1599 assert!(
1601 balance_result.is_err(),
1602 "Expected error for missing EUR->USD conversion"
1603 );
1604
1605 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 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 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 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 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 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 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 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 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 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 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 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 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 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 for (commodity, balance) in balances {
1971 match balance.to_integer() {
1972 100 => {
1973 assert_eq!(commodity.id, usd_id);
1975 }
1976 50 => {
1977 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 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 assert!(
1997 balance_result_usd.is_err(),
1998 "Expected error for missing EUR->USD conversion"
1999 );
2000
2001 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 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 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 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 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 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 let tx2_id = Uuid::new_v4();
2149 let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); 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 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 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 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 assert!(
2233 balance2_eur_before.is_err(),
2234 "Expected error for missing USD->EUR conversion"
2235 );
2236
2237 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 let tx3_id = Uuid::new_v4();
2252 let even_later = DateTime::<Utc>::from_timestamp(1640996400, 0).unwrap(); 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 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 assert!(
2313 balance2_eur_after.is_err(),
2314 "Expected error for missing USD->EUR conversion (account isolation test)"
2315 );
2316
2317 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}