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 {
52 id: row.id,
53 parent: row.parent_id,
54 };
55
56 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 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 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 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 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 let Some(row) = account_query {
289 let account = Account {
290 id: row.id,
291 parent: row.parent,
292 };
293
294 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 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 let rows = sqlx::query_file!("sql/select/accounts/commodities.sql", &account_id)
343 .fetch_all(&mut *conn)
344 .await?;
345
346 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 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 return Ok(Some(CmdResult::Rational(Rational64::new(0, 1))));
387 }
388
389 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 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 total_balance += split_value;
403 } else {
404 if let (Some(price_num), Some(price_denom)) = (split_data.price_num, split_data.price_denom) {
406 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 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 if unique_commodities.len() == 1 {
434 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 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 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 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 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 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 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 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 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 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 CreateAccount::new()
588 .name("Test Account".to_string())
589 .user_id(user.id)
590 .run()
591 .await?;
592
593 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 assert_eq!(tags.len(), 1); 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 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 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 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 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 assert_eq!(tags.len(), 1); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let tx1_id = Uuid::new_v4();
1255 let now = DateTime::<Utc>::from_timestamp(1640995200, 0).unwrap(); 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, 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 let tx2_id = Uuid::new_v4();
1299 let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); 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 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, currency_split: None,
1349 value_num: 1176, value_denom: 1000,
1351 };
1352
1353 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 let balance_result1 = GetBalance::new()
1372 .user_id(user.id)
1373 .account_id(account1_id)
1374 .run()
1375 .await
1376 .unwrap();
1377
1378 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 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 assert!(
1400 balance_result2.is_err(),
1401 "Expected error for missing split-specific EUR->USD conversion"
1402 );
1403
1404 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 let balance_result3 = GetBalance::new()
1419 .user_id(user.id)
1420 .account_id(account2_id)
1421 .run()
1422 .await
1423 .unwrap();
1424
1425 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 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 assert!(
1447 balance_result4.is_err(),
1448 "Expected error for missing split-specific USD->EUR conversion"
1449 );
1450
1451 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 let balance_result5 = GetBalance::new()
1466 .user_id(user.id)
1467 .account_id(account2_id)
1468 .commodity_id(usd_id) .run()
1470 .await;
1471
1472 assert!(
1474 balance_result5.is_err(),
1475 "Expected error for missing split-specific EUR->USD conversion"
1476 );
1477
1478 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 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 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 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 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 let balance_result = GetBalance::new()
1645 .user_id(user.id)
1646 .account_id(account_id)
1647 .commodity_id(usd_id) .run()
1649 .await;
1650
1651 assert!(
1653 balance_result.is_err(),
1654 "Expected error for missing EUR->USD conversion"
1655 );
1656
1657 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 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 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 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 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 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 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 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 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 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 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 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 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 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 for (commodity, balance) in balances {
2023 match balance.to_integer() {
2024 100 => {
2025 assert_eq!(commodity.id, usd_id);
2027 }
2028 50 => {
2029 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 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 assert!(
2049 balance_result_usd.is_err(),
2050 "Expected error for missing EUR->USD conversion"
2051 );
2052
2053 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 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 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 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 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 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 let tx2_id = Uuid::new_v4();
2201 let later = DateTime::<Utc>::from_timestamp(1640995800, 0).unwrap(); 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 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 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 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 assert!(
2285 balance2_eur_before.is_err(),
2286 "Expected error for missing USD->EUR conversion"
2287 );
2288
2289 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 let tx3_id = Uuid::new_v4();
2304 let even_later = DateTime::<Utc>::from_timestamp(1640996400, 0).unwrap(); 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 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 assert!(
2365 balance2_eur_after.is_err(),
2366 "Expected error for missing USD->EUR conversion (account isolation test)"
2367 );
2368
2369 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}