1use cfg_if::cfg_if;
2#[cfg(not(feature = "scripting"))]
3use finance::{price::Price, split::Split};
4use finance::{tag::Tag, transaction::Transaction};
5#[cfg(feature = "scripting")]
6use scripting::ScriptExecutor;
7use sqlx::{
8 Acquire,
9 types::Uuid,
10 types::chrono::{DateTime, Utc},
11};
12use std::{collections::HashMap, fmt::Debug};
13use supp_macro::command;
14
15#[cfg(feature = "scripting")]
16use crate::script::TransactionState;
17use crate::{config::ConfigError, user::User};
18
19use super::{CmdError, CmdResult, FinanceEntity, PaginationInfo};
20
21command! {
22 CreateTransaction {
23 #[required]
24 user_id: Uuid,
25 #[required]
26 splits: Vec<FinanceEntity>,
27 #[required]
28 id: Uuid,
29 #[required]
30 post_date: DateTime<Utc>,
31 #[required]
32 enter_date: DateTime<Utc>,
33 #[optional]
34 prices: Vec<FinanceEntity>,
35 #[optional]
36 note: String,
37 #[optional]
38 split_tags: Vec<(Uuid, Tag)>,
39 } => {
40
41
42 let user = User { id: user_id };
43
44 let mut conn = user.get_connection().await.map_err(|err| {
45 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
46 ConfigError::DB
47 })?;
48
49 let tx = Transaction {
50 id,
51 post_date,
52 enter_date,
53 };
54
55 cfg_if! {
56 if #[cfg(feature = "scripting")] {
57 let (transaction, splits, prices, transaction_tags, split_tags) = {
58 let scripts: Vec<(Uuid, Vec<u8>)> = sqlx::query_file!("sql/select/scripts/enabled.sql")
59 .fetch_all(&mut *conn)
60 .await?
61 .into_iter()
62 .map(|row| (row.id, row.bytecode))
63 .collect();
64
65 let state = TransactionState::new(tx)
66 .with(splits)
67 .with(prices.unwrap_or_default())
68 .with_note(note)
69 .with_split_tags(split_tags.unwrap_or_default());
70
71 let state = if scripts.is_empty() {
72 state
73 } else {
74 tokio::task::spawn_blocking(move || {
75 let executor = ScriptExecutor::new();
76 state.run_scripts(&executor, &scripts)
77 })
78 .await
79 .map_err(|e| CmdError::Script(format!("{e:?}")))?
80 .map_err(|e| {
81 log::error!("{}", t!("Script execution failed: %{err}", err = e : {:?}));
82 CmdError::Script(format!("{e:?}"))
83 })?
84 };
85
86 (state.transaction, state.splits, state.prices, state.transaction_tags, state.split_tags)
87 };
88 } else {
89 let (transaction, splits, prices, transaction_tags, split_tags) = {
90 let splits: Vec<Split> = splits
91 .into_iter()
92 .filter_map(|e| match e {
93 FinanceEntity::Split(s) => Some(s),
94 _ => None,
95 })
96 .collect();
97
98 let prices: Vec<Price> = prices
99 .map(|ps| {
100 ps.into_iter()
101 .filter_map(|e| match e {
102 FinanceEntity::Price(p) => Some(p),
103 _ => None,
104 })
105 .collect()
106 })
107 .unwrap_or_default();
108
109 let transaction_tags: Vec<Tag> = note
110 .map(|n| {
111 vec![Tag {
112 id: Uuid::new_v4(),
113 tag_name: "note".to_string(),
114 tag_value: n,
115 description: None,
116 }]
117 })
118 .unwrap_or_default();
119
120 let split_tags: Vec<(Uuid, Tag)> = split_tags.unwrap_or_default();
121
122 (tx, splits, prices, transaction_tags, split_tags)
123 };
124 }
125 }
126
127 let mut ticket = transaction.enter(&mut *conn).await?;
129
130 let split_refs: Vec<_> = splits.iter().collect();
131 ticket.add_splits(&split_refs).await?;
132
133 if !prices.is_empty() {
134 let price_refs: Vec<_> = prices.iter().collect();
135 ticket.add_conversions(&price_refs).await?;
136 }
137
138 if !transaction_tags.is_empty() {
139 let tag_refs: Vec<_> = transaction_tags.iter().collect();
140 ticket.add_tags(&tag_refs).await?;
141 }
142
143 if !split_tags.is_empty() {
144 ticket.add_split_tags(&split_tags).await?;
145 }
146
147 ticket.commit().await?;
148
149 Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(transaction))))
150 }
151}
152
153command! {
154 ListTransactions {
155 #[required]
156 user_id: Uuid,
157 #[optional]
158 account: Uuid,
159 #[optional]
160 limit: i64,
161 #[optional]
162 offset: i64,
163 #[optional]
164 date_from: DateTime<Utc>,
165 #[optional]
166 date_to: DateTime<Utc>,
167 } => {
168 let user = User { id: user_id };
169 let mut conn = user.get_connection().await.map_err(|err| {
170 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
171 ConfigError::DB
172 })?;
173
174 let account_uuid = account.as_ref();
175 let effective_limit = limit.unwrap_or(20);
176 let effective_offset = offset.unwrap_or(0);
177 let date_from_ref = date_from.as_ref();
178 let date_to_ref = date_to.as_ref();
179
180 let count_result = sqlx::query_file!(
181 "sql/count/transactions/filtered.sql",
182 account_uuid,
183 date_from_ref,
184 date_to_ref
185 )
186 .fetch_one(&mut *conn)
187 .await?;
188
189 let total_count = count_result.count.unwrap_or(0);
190
191 let transactions = sqlx::query_file!(
192 "sql/select/transactions/paginated.sql",
193 account_uuid,
194 date_from_ref,
195 date_to_ref,
196 effective_limit,
197 effective_offset
198 )
199 .fetch_all(&mut *conn)
200 .await?;
201
202 let mut tagged_transactions = Vec::new();
203 for tx_row in transactions {
204 let transaction = Transaction {
205 id: tx_row.id,
206 post_date: tx_row.post_date,
207 enter_date: tx_row.enter_date,
208 };
209
210 let tags: HashMap<String, FinanceEntity> =
211 sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
212 .fetch_all(&mut *conn)
213 .await?
214 .into_iter()
215 .map(|row| {
216 (
217 row.tag_name.clone(),
218 FinanceEntity::Tag(Tag {
219 id: row.id,
220 tag_name: row.tag_name,
221 tag_value: row.tag_value,
222 description: row.description,
223 }),
224 )
225 })
226 .collect();
227
228 tagged_transactions.push((FinanceEntity::Transaction(transaction), tags));
229 }
230
231 let pagination = PaginationInfo {
232 total_count,
233 limit: effective_limit,
234 offset: effective_offset,
235 has_more: effective_offset + (tagged_transactions.len() as i64) < total_count,
236 };
237
238 Ok(Some(CmdResult::TaggedEntities {
239 entities: tagged_transactions,
240 pagination: Some(pagination),
241 }))
242 }
243}
244
245command! {
246 GetTransaction {
247 #[required]
248 user_id: Uuid,
249 #[required]
250 transaction_id: Uuid,
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 tx_row = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
259 .fetch_optional(&mut *conn)
260 .await?;
261
262 if let Some(tx_row) = tx_row {
263 let transaction = Transaction {
264 id: tx_row.id,
265 post_date: tx_row.post_date,
266 enter_date: tx_row.enter_date,
267 };
268
269 let tags: HashMap<String, FinanceEntity> =
270 sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
271 .fetch_all(&mut *conn)
272 .await?
273 .into_iter()
274 .map(|row| {
275 (
276 row.tag_name.clone(),
277 FinanceEntity::Tag(Tag {
278 id: row.id,
279 tag_name: row.tag_name,
280 tag_value: row.tag_value,
281 description: row.description,
282 }),
283 )
284 })
285 .collect();
286
287 Ok(Some(CmdResult::TaggedEntities {
288 entities: vec![(FinanceEntity::Transaction(transaction), tags)],
289 pagination: None,
290 }))
291 } else {
292 Ok(None)
293 }
294 }
295}
296
297command! {
298 UpdateTransaction {
299 #[required]
300 user_id: Uuid,
301 #[required]
302 transaction_id: Uuid,
303 #[optional]
304 splits: Vec<FinanceEntity>,
305 #[optional]
306 post_date: DateTime<Utc>,
307 #[optional]
308 enter_date: DateTime<Utc>,
309 #[optional]
310 note: String,
311 #[optional]
312 prices: Vec<FinanceEntity>,
313 #[optional]
314 tags: HashMap<String, FinanceEntity>,
315 } => {
316 let user = User { id: user_id };
317 let mut conn = user.get_connection().await.map_err(|err| {
318 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
319 ConfigError::DB
320 })?;
321
322 let mut tx = conn.begin().await?;
323
324 let existing = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
325 .fetch_optional(&mut *tx)
326 .await?
327 .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
328
329 let final_post_date = post_date.unwrap_or(existing.post_date);
330 let final_enter_date = enter_date.unwrap_or(existing.enter_date);
331
332 if let Some(ref new_splits) = splits {
334 let mut commodity_sums: std::collections::HashMap<Uuid, num_rational::Rational64> =
335 std::collections::HashMap::new();
336
337 for entity in new_splits {
338 if let FinanceEntity::Split(split) = entity {
339 if split.tx_id != transaction_id {
341 return Err(CmdError::Args("Split transaction ID mismatch".to_string()));
342 }
343
344 let split_value =
345 num_rational::Rational64::new(split.value_num, split.value_denom);
346 *commodity_sums.entry(split.commodity_id).or_insert(
347 num_rational::Rational64::new(0, 1),
348 ) += split_value;
349 } else {
350 return Err(CmdError::Args("Invalid entity type in splits".to_string()));
351 }
352 }
353
354 if commodity_sums.len() == 1 {
358 for sum in commodity_sums.values() {
359 if *sum != num_rational::Rational64::new(0, 1) {
360 return Err(CmdError::Args("Splits must sum to zero".to_string()));
361 }
362 }
363 }
364 }
365
366 sqlx::query_file!(
368 "sql/update/transactions/update.sql",
369 transaction_id,
370 final_post_date,
371 final_enter_date
372 )
373 .execute(&mut *tx)
374 .await?;
375
376 if let Some(new_splits) = splits {
378 sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
380 .execute(&mut *tx)
381 .await?;
382
383 sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
384 .execute(&mut *tx)
385 .await?;
386
387 sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
388 .execute(&mut *tx)
389 .await?;
390
391 for entity in new_splits {
393 if let FinanceEntity::Split(split) = entity {
394 sqlx::query_file!(
395 "sql/insert/splits/split.sql",
396 split.id,
397 split.tx_id,
398 split.account_id,
399 split.commodity_id,
400 split.reconcile_state,
401 split.reconcile_date,
402 split.value_num,
403 split.value_denom,
404 split.lot_id
405 )
406 .execute(&mut *tx)
407 .await?;
408 }
409 }
410 }
411
412 if let Some(ref new_prices) = prices {
414 for entity in new_prices {
415 if let FinanceEntity::Price(_) = entity {
416 } else {
418 return Err(CmdError::Args("Invalid entity type in prices".to_string()));
419 }
420 }
421 }
422
423 if let Some(ref new_tags) = tags {
425 for entity in new_tags.values() {
426 if let FinanceEntity::Tag(_) = entity {
427 } else {
429 return Err(CmdError::Args("Invalid entity type in tags".to_string()));
430 }
431 }
432 }
433
434 if let Some(new_prices) = prices {
436 for entity in new_prices {
437 if let FinanceEntity::Price(price) = entity {
438 sqlx::query_file!(
439 "sql/insert/prices/price.sql",
440 price.id,
441 price.commodity_id,
442 price.currency_id,
443 price.commodity_split,
444 price.currency_split,
445 price.date,
446 price.value_num,
447 price.value_denom
448 )
449 .execute(&mut *tx)
450 .await?;
451 }
452 }
453 }
454
455 if let Some(new_tags) = tags {
457 sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
458 .execute(&mut *tx)
459 .await?;
460
461 for (_, entity) in new_tags {
462 if let FinanceEntity::Tag(tag) = entity {
463 sqlx::query_file!(
464 "sql/insert/transaction_tags/transaction_tag.sql",
465 transaction_id,
466 tag.id
467 )
468 .execute(&mut *tx)
469 .await?;
470 }
471 }
472 }
473
474 if let Some(note_value) = note {
476 sqlx::query!("DELETE FROM transaction_tags WHERE tx_id = $1 AND tag_id IN (SELECT id FROM tags WHERE tag_name = 'note')", transaction_id)
478 .execute(&mut *tx)
479 .await?;
480
481 if !note_value.trim().is_empty() {
482 let note_tag_id = Tag {
483 id: Uuid::new_v4(),
484 tag_name: "note".to_string(),
485 tag_value: note_value,
486 description: None,
487 }
488 .commit(&mut *tx)
489 .await?;
490
491 sqlx::query_file!(
492 "sql/insert/transaction_tags/transaction_tag.sql",
493 transaction_id,
494 note_tag_id
495 )
496 .execute(&mut *tx)
497 .await?;
498 }
499 }
500
501 tx.commit().await?;
502
503 let updated_transaction = Transaction {
504 id: transaction_id,
505 post_date: final_post_date,
506 enter_date: final_enter_date,
507 };
508
509 Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(updated_transaction))))
510 }
511}
512
513command! {
514 DeleteTransaction {
515 #[required]
516 user_id: Uuid,
517 #[required]
518 transaction_id: Uuid,
519 } => {
520 let user = User { id: user_id };
521 let mut conn = user.get_connection().await.map_err(|err| {
522 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
523 ConfigError::DB
524 })?;
525
526 sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
527 .fetch_optional(&mut *conn)
528 .await?
529 .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
530
531 let mut tx = conn.begin().await?;
532
533 let tag_ids_to_check: Vec<Uuid> = sqlx::query_file!(
534 "sql/select/tags/by_transaction_and_splits.sql",
535 transaction_id
536 )
537 .fetch_all(&mut *tx)
538 .await?
539 .into_iter()
540 .filter_map(|row| row.tag_id)
541 .collect();
542
543 sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
544 .execute(&mut *tx)
545 .await?;
546
547 sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
548 .execute(&mut *tx)
549 .await?;
550
551 sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
552 .execute(&mut *tx)
553 .await?;
554
555 for tag_id in tag_ids_to_check {
556 let is_orphaned = sqlx::query_file!("sql/check/tags/is_orphaned.sql", tag_id)
557 .fetch_one(&mut *tx)
558 .await?
559 .is_orphaned
560 .unwrap_or(false);
561
562 if is_orphaned {
563 sqlx::query_file!("sql/delete/tags/by_id.sql", tag_id)
564 .execute(&mut *tx)
565 .await?;
566 }
567 }
568
569 sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
570 .execute(&mut *tx)
571 .await?;
572
573 sqlx::query_file!("sql/delete/transactions/by_id.sql", transaction_id)
574 .execute(&mut *tx)
575 .await?;
576
577 tx.commit().await?;
578
579 Ok(Some(CmdResult::String("Transaction deleted successfully".to_string())))
580 }
581}
582
583#[cfg(test)]
584mod command_tests {
585 use super::*;
586 use crate::{
587 command::{account::CreateAccount, commodity::CreateCommodity},
588 db::DB_POOL,
589 };
590 use chrono::Duration;
591 use finance::{account::Account, price::Price, split::Split};
592 use sqlx::PgPool;
593 use supp_macro::local_db_sqlx_test;
594 use tokio::sync::OnceCell;
595
596 static CONTEXT: OnceCell<()> = OnceCell::const_new();
598 static USER: OnceCell<User> = OnceCell::const_new();
599
600 async fn setup() {
601 CONTEXT
602 .get_or_init(|| async {
603 #[cfg(feature = "testlog")]
604 let _ = env_logger::builder()
605 .is_test(true)
606 .filter_level(log::LevelFilter::Trace)
607 .try_init();
608 })
609 .await;
610 USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
611 .await;
612 }
613
614 #[local_db_sqlx_test]
615 async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
616 let user = USER.get().unwrap();
617 user.commit()
618 .await
619 .expect("Failed to commit user to database");
620
621 let commodity_result = CreateCommodity::new()
623 .symbol("TST".to_string())
624 .name("Test Commodity".to_string())
625 .user_id(user.id)
626 .run()
627 .await?;
628
629 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
631 uuid::Uuid::parse_str(&id)?
632 } else {
633 panic!("Expected commodity ID string result");
634 };
635
636 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
638 CreateAccount::new()
639 .name("Account 1".to_string())
640 .user_id(user.id)
641 .run()
642 .await?
643 {
644 account
645 } else {
646 panic!("Expected account entity result");
647 };
648
649 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
650 CreateAccount::new()
651 .name("Account 2".to_string())
652 .user_id(user.id)
653 .run()
654 .await?
655 {
656 account
657 } else {
658 panic!("Expected account entity result");
659 };
660
661 let tx_id = Uuid::new_v4();
662
663 let split1 = Split::builder()
665 .id(Uuid::new_v4())
666 .tx_id(tx_id)
667 .account_id(account1.id)
668 .commodity_id(commodity_id)
669 .value_num(100)
670 .value_denom(1)
671 .build()?;
672
673 let split2 = Split::builder()
674 .id(Uuid::new_v4())
675 .tx_id(tx_id)
676 .account_id(account2.id)
677 .commodity_id(commodity_id)
678 .value_num(-100)
679 .value_denom(1)
680 .build()?;
681
682 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
684 let now = Utc::now();
685
686 if let Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) = CreateTransaction::new()
687 .user_id(user.id)
688 .splits(splits)
689 .id(tx_id)
690 .post_date(now)
691 .enter_date(now)
692 .run()
693 .await?
694 {
695 assert!(!tx.id.is_nil());
696
697 let mut conn = user.get_connection().await?;
699 let splits = sqlx::query_file!("sql/count/splits/by_transaction.sql", tx.id)
700 .fetch_one(&mut *conn)
701 .await?;
702 assert_eq!(splits.count, Some(2));
703 } else {
704 panic!("Expected transaction entity result");
705 }
706 }
707 #[local_db_sqlx_test]
708 async fn test_list_transactions_empty(pool: PgPool) -> anyhow::Result<()> {
709 let user = USER.get().unwrap();
710 user.commit()
711 .await
712 .expect("Failed to commit user to database");
713
714 if let Some(CmdResult::TaggedEntities {
715 entities,
716 pagination: Some(pagination),
717 }) = ListTransactions::new().user_id(user.id).run().await?
718 {
719 assert!(
720 entities.is_empty(),
721 "Expected no transactions in empty database"
722 );
723 assert_eq!(pagination.total_count, 0);
724 assert_eq!(pagination.limit, 20);
725 assert_eq!(pagination.offset, 0);
726 assert!(!pagination.has_more);
727 } else {
728 panic!("Expected TaggedEntities result with pagination");
729 }
730 }
731
732 #[local_db_sqlx_test]
733 async fn test_list_transactions_with_data(pool: PgPool) -> anyhow::Result<()> {
734 let user = USER.get().unwrap();
735 user.commit()
736 .await
737 .expect("Failed to commit user to database");
738
739 let commodity_result = CreateCommodity::new()
741 .symbol("TST".to_string())
742 .name("Test Commodity".to_string())
743 .user_id(user.id)
744 .run()
745 .await?;
746
747 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
749 uuid::Uuid::parse_str(&id)?
750 } else {
751 panic!("Expected commodity ID string result");
752 };
753
754 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
756 CreateAccount::new()
757 .name("Account 1".to_string())
758 .user_id(user.id)
759 .run()
760 .await?
761 {
762 account
763 } else {
764 panic!("Expected account entity result");
765 };
766
767 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
768 CreateAccount::new()
769 .name("Account 2".to_string())
770 .user_id(user.id)
771 .run()
772 .await?
773 {
774 account
775 } else {
776 panic!("Expected account entity result");
777 };
778
779 let tx_id = Uuid::new_v4();
781 let now = Utc::now();
782
783 let split1 = Split {
784 id: Uuid::new_v4(),
785 tx_id,
786 account_id: account1.id,
787 commodity_id,
788 value_num: -100,
789 value_denom: 1,
790 reconcile_state: None,
791 reconcile_date: None,
792 lot_id: None,
793 };
794
795 let split2 = Split {
796 id: Uuid::new_v4(),
797 tx_id,
798 account_id: account2.id,
799 commodity_id,
800 value_num: 100,
801 value_denom: 1,
802 reconcile_state: None,
803 reconcile_date: None,
804 lot_id: None,
805 };
806
807 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
808 CreateTransaction::new()
809 .user_id(user.id)
810 .splits(splits)
811 .id(tx_id)
812 .post_date(now)
813 .enter_date(now)
814 .run()
815 .await?;
816
817 if let Some(CmdResult::TaggedEntities {
819 entities,
820 pagination: Some(pagination),
821 }) = ListTransactions::new().user_id(user.id).run().await?
822 {
823 assert_eq!(entities.len(), 1, "Expected one transaction");
824 assert_eq!(pagination.total_count, 1);
825
826 let (entity, _tags) = &entities[0];
827 if let FinanceEntity::Transaction(tx) = entity {
828 assert_eq!(tx.id, tx_id);
829 } else {
830 panic!("Expected Transaction entity");
831 }
832 } else {
833 panic!("Expected TaggedEntities result with pagination");
834 }
835
836 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListTransactions::new()
838 .user_id(user.id)
839 .account(account1.id)
840 .run()
841 .await?
842 {
843 assert_eq!(entities.len(), 1, "Expected one transaction for account1");
844 } else {
845 panic!("Expected TaggedEntities result");
846 }
847
848 if let Some(CmdResult::TaggedEntities { entities, .. }) = ListTransactions::new()
850 .user_id(user.id)
851 .account(Uuid::new_v4())
852 .run()
853 .await?
854 {
855 assert_eq!(
856 entities.len(),
857 0,
858 "Expected no transactions for non-existent account"
859 );
860 } else {
861 panic!("Expected TaggedEntities result");
862 }
863 }
864
865 #[local_db_sqlx_test]
866 async fn test_get_transaction(pool: PgPool) -> anyhow::Result<()> {
867 let user = USER.get().unwrap();
868 user.commit()
869 .await
870 .expect("Failed to commit user to database");
871
872 let commodity_result = CreateCommodity::new()
874 .symbol("TST".to_string())
875 .name("Test Commodity".to_string())
876 .user_id(user.id)
877 .run()
878 .await?;
879
880 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
881 uuid::Uuid::parse_str(&id)?
882 } else {
883 panic!("Expected commodity ID string result");
884 };
885
886 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
888 CreateAccount::new()
889 .name("Account 1".to_string())
890 .user_id(user.id)
891 .run()
892 .await?
893 {
894 account
895 } else {
896 panic!("Expected account entity result");
897 };
898
899 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
900 CreateAccount::new()
901 .name("Account 2".to_string())
902 .user_id(user.id)
903 .run()
904 .await?
905 {
906 account
907 } else {
908 panic!("Expected account entity result");
909 };
910
911 let tx_id = Uuid::new_v4();
913 let split1 = Split {
914 id: Uuid::new_v4(),
915 tx_id,
916 account_id: account1.id,
917 commodity_id,
918 value_num: -100,
919 value_denom: 1,
920 reconcile_state: None,
921 reconcile_date: None,
922 lot_id: None,
923 };
924 let split2 = Split {
925 id: Uuid::new_v4(),
926 tx_id,
927 account_id: account2.id,
928 commodity_id,
929 value_num: 100,
930 value_denom: 1,
931 reconcile_state: None,
932 reconcile_date: None,
933 lot_id: None,
934 };
935 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
936 let now = Utc::now();
937
938 CreateTransaction::new()
939 .user_id(user.id)
940 .splits(splits)
941 .id(tx_id)
942 .post_date(now)
943 .enter_date(now)
944 .note("Test transaction".to_string())
945 .run()
946 .await?;
947
948 if let Some(CmdResult::TaggedEntities { entities, .. }) = GetTransaction::new()
950 .user_id(user.id)
951 .transaction_id(tx_id)
952 .run()
953 .await?
954 {
955 assert_eq!(entities.len(), 1, "Expected one transaction");
956 let (entity, _tags) = &entities[0];
957 if let FinanceEntity::Transaction(tx) = entity {
958 assert_eq!(tx.id, tx_id);
959 } else {
960 panic!("Expected Transaction entity");
961 }
962 } else {
963 panic!("Expected TaggedEntities result");
964 }
965
966 let result = GetTransaction::new()
968 .user_id(user.id)
969 .transaction_id(Uuid::new_v4())
970 .run()
971 .await?;
972 assert!(
973 result.is_none(),
974 "Expected None for non-existent transaction"
975 );
976 }
977
978 #[local_db_sqlx_test]
979 async fn test_update_transaction(pool: PgPool) -> anyhow::Result<()> {
980 let user = USER.get().unwrap();
981 user.commit()
982 .await
983 .expect("Failed to commit user to database");
984
985 let commodity_result = CreateCommodity::new()
987 .symbol("TST".to_string())
988 .name("Test Commodity".to_string())
989 .user_id(user.id)
990 .run()
991 .await?;
992
993 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
994 uuid::Uuid::parse_str(&id)?
995 } else {
996 panic!("Expected commodity ID string result");
997 };
998
999 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1001 CreateAccount::new()
1002 .name("Account 1".to_string())
1003 .user_id(user.id)
1004 .run()
1005 .await?
1006 {
1007 account
1008 } else {
1009 panic!("Expected account entity result");
1010 };
1011
1012 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1013 CreateAccount::new()
1014 .name("Account 2".to_string())
1015 .user_id(user.id)
1016 .run()
1017 .await?
1018 {
1019 account
1020 } else {
1021 panic!("Expected account entity result");
1022 };
1023
1024 let tx_id = Uuid::new_v4();
1026 let split1 = Split {
1027 id: Uuid::new_v4(),
1028 tx_id,
1029 account_id: account1.id,
1030 commodity_id,
1031 value_num: -100,
1032 value_denom: 1,
1033 reconcile_state: None,
1034 reconcile_date: None,
1035 lot_id: None,
1036 };
1037 let split2 = Split {
1038 id: Uuid::new_v4(),
1039 tx_id,
1040 account_id: account2.id,
1041 commodity_id,
1042 value_num: 100,
1043 value_denom: 1,
1044 reconcile_state: None,
1045 reconcile_date: None,
1046 lot_id: None,
1047 };
1048 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1049 let now = Utc::now();
1050
1051 CreateTransaction::new()
1052 .user_id(user.id)
1053 .splits(splits)
1054 .id(tx_id)
1055 .post_date(now)
1056 .enter_date(now)
1057 .note("Original note".to_string())
1058 .run()
1059 .await?;
1060
1061 let new_note = "Updated note".to_string();
1063 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1064 UpdateTransaction::new()
1065 .user_id(user.id)
1066 .transaction_id(tx_id)
1067 .note(new_note.clone())
1068 .run()
1069 .await?
1070 {
1071 assert_eq!(updated_tx.id, tx_id);
1072 } else {
1073 panic!("Expected Transaction entity result");
1074 }
1075
1076 let new_split1 = Split {
1078 id: Uuid::new_v4(),
1079 tx_id,
1080 account_id: account1.id,
1081 commodity_id,
1082 value_num: -200,
1083 value_denom: 1,
1084 reconcile_state: None,
1085 reconcile_date: None,
1086 lot_id: None,
1087 };
1088 let new_split2 = Split {
1089 id: Uuid::new_v4(),
1090 tx_id,
1091 account_id: account2.id,
1092 commodity_id,
1093 value_num: 200,
1094 value_denom: 1,
1095 reconcile_state: None,
1096 reconcile_date: None,
1097 lot_id: None,
1098 };
1099 let new_splits = vec![
1100 FinanceEntity::Split(new_split1),
1101 FinanceEntity::Split(new_split2),
1102 ];
1103
1104 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1105 UpdateTransaction::new()
1106 .user_id(user.id)
1107 .transaction_id(tx_id)
1108 .splits(new_splits)
1109 .run()
1110 .await?
1111 {
1112 assert_eq!(updated_tx.id, tx_id);
1113 } else {
1114 panic!("Expected Transaction entity result");
1115 }
1116
1117 let unbalanced_split1 = Split {
1119 id: Uuid::new_v4(),
1120 tx_id,
1121 account_id: account1.id,
1122 commodity_id,
1123 value_num: -100, value_denom: 1,
1125 reconcile_state: None,
1126 reconcile_date: None,
1127 lot_id: None,
1128 };
1129 let unbalanced_split2 = Split {
1130 id: Uuid::new_v4(),
1131 tx_id,
1132 account_id: account2.id,
1133 commodity_id,
1134 value_num: 50, value_denom: 1,
1136 reconcile_state: None,
1137 reconcile_date: None,
1138 lot_id: None,
1139 };
1140 let unbalanced_splits = vec![
1141 FinanceEntity::Split(unbalanced_split1),
1142 FinanceEntity::Split(unbalanced_split2),
1143 ];
1144
1145 let result = UpdateTransaction::new()
1146 .user_id(user.id)
1147 .transaction_id(tx_id)
1148 .splits(unbalanced_splits)
1149 .run()
1150 .await;
1151 assert!(result.is_err(), "Expected error for unbalanced splits");
1152
1153 if let Some(CmdResult::TaggedEntities { entities, .. }) = GetTransaction::new()
1155 .user_id(user.id)
1156 .transaction_id(tx_id)
1157 .run()
1158 .await?
1159 {
1160 assert_eq!(entities.len(), 1, "Expected one transaction");
1161 } else {
1163 panic!("Expected transaction to still exist after failed update");
1164 }
1165
1166 let result = UpdateTransaction::new()
1168 .user_id(user.id)
1169 .transaction_id(Uuid::new_v4())
1170 .note("Should fail".to_string())
1171 .run()
1172 .await;
1173 assert!(
1174 result.is_err(),
1175 "Expected error for non-existent transaction"
1176 );
1177 }
1178
1179 #[local_db_sqlx_test]
1180 async fn test_update_transaction_atomicity(pool: PgPool) -> anyhow::Result<()> {
1181 let user = USER.get().unwrap();
1182 user.commit()
1183 .await
1184 .expect("Failed to commit user to database");
1185
1186 let commodity_result = CreateCommodity::new()
1188 .symbol("TST".to_string())
1189 .name("Test Commodity".to_string())
1190 .user_id(user.id)
1191 .run()
1192 .await?;
1193
1194 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1195 uuid::Uuid::parse_str(&id)?
1196 } else {
1197 panic!("Expected commodity ID string result");
1198 };
1199
1200 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1202 CreateAccount::new()
1203 .name("Account 1".to_string())
1204 .user_id(user.id)
1205 .run()
1206 .await?
1207 {
1208 account
1209 } else {
1210 panic!("Expected account entity result");
1211 };
1212
1213 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1214 CreateAccount::new()
1215 .name("Account 2".to_string())
1216 .user_id(user.id)
1217 .run()
1218 .await?
1219 {
1220 account
1221 } else {
1222 panic!("Expected account entity result");
1223 };
1224
1225 let tx_id = Uuid::new_v4();
1227 let split1 = Split {
1228 id: Uuid::new_v4(),
1229 tx_id,
1230 account_id: account1.id,
1231 commodity_id,
1232 value_num: -100,
1233 value_denom: 1,
1234 reconcile_state: None,
1235 reconcile_date: None,
1236 lot_id: None,
1237 };
1238 let split2 = Split {
1239 id: Uuid::new_v4(),
1240 tx_id,
1241 account_id: account2.id,
1242 commodity_id,
1243 value_num: 100,
1244 value_denom: 1,
1245 reconcile_state: None,
1246 reconcile_date: None,
1247 lot_id: None,
1248 };
1249 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1250 let now = Utc::now();
1251
1252 CreateTransaction::new()
1253 .user_id(user.id)
1254 .splits(splits)
1255 .id(tx_id)
1256 .post_date(now)
1257 .enter_date(now)
1258 .note("Original transaction".to_string())
1259 .run()
1260 .await?;
1261
1262 let wrong_tx_id = Uuid::new_v4();
1264 let invalid_split = Split {
1265 id: Uuid::new_v4(),
1266 tx_id: wrong_tx_id, account_id: account1.id,
1268 commodity_id,
1269 value_num: -50,
1270 value_denom: 1,
1271 reconcile_state: None,
1272 reconcile_date: None,
1273 lot_id: None,
1274 };
1275 let valid_split = Split {
1276 id: Uuid::new_v4(),
1277 tx_id,
1278 account_id: account2.id,
1279 commodity_id,
1280 value_num: 50,
1281 value_denom: 1,
1282 reconcile_state: None,
1283 reconcile_date: None,
1284 lot_id: None,
1285 };
1286 let mismatched_splits = vec![
1287 FinanceEntity::Split(invalid_split),
1288 FinanceEntity::Split(valid_split),
1289 ];
1290
1291 let result = UpdateTransaction::new()
1292 .user_id(user.id)
1293 .transaction_id(tx_id)
1294 .splits(mismatched_splits)
1295 .run()
1296 .await;
1297
1298 assert!(
1299 result.is_err(),
1300 "Expected error for split transaction ID mismatch"
1301 );
1302 if let Err(CmdError::Args(msg)) = result {
1303 assert!(msg.contains("Split transaction ID mismatch"));
1304 } else {
1305 panic!("Expected CmdError::Args with transaction ID mismatch message");
1306 }
1307
1308 if let Some(CmdResult::TaggedEntities { entities, .. }) = GetTransaction::new()
1310 .user_id(user.id)
1311 .transaction_id(tx_id)
1312 .run()
1313 .await?
1314 {
1315 assert_eq!(entities.len(), 1, "Expected one transaction");
1316 } else {
1317 panic!("Expected transaction to still exist after failed update");
1318 }
1319
1320 let invalid_splits = vec![
1322 FinanceEntity::Account(Account {
1323 id: account1.id,
1324 parent: account1.parent,
1325 }), ];
1327
1328 let result = UpdateTransaction::new()
1329 .user_id(user.id)
1330 .transaction_id(tx_id)
1331 .splits(invalid_splits)
1332 .run()
1333 .await;
1334
1335 assert!(
1336 result.is_err(),
1337 "Expected error for invalid entity type in splits"
1338 );
1339 if let Err(CmdError::Args(msg)) = result {
1340 assert!(msg.contains("Invalid entity type in splits"));
1341 } else {
1342 panic!("Expected CmdError::Args with invalid entity type message");
1343 }
1344
1345 let invalid_prices = vec![
1347 FinanceEntity::Account(Account {
1348 id: account1.id,
1349 parent: account1.parent,
1350 }), ];
1352
1353 let result = UpdateTransaction::new()
1354 .user_id(user.id)
1355 .transaction_id(tx_id)
1356 .prices(invalid_prices)
1357 .run()
1358 .await;
1359
1360 assert!(
1361 result.is_err(),
1362 "Expected error for invalid entity type in prices"
1363 );
1364 if let Err(CmdError::Args(msg)) = result {
1365 assert!(msg.contains("Invalid entity type in prices"));
1366 } else {
1367 panic!("Expected CmdError::Args with invalid entity type message");
1368 }
1369
1370 let mut invalid_tags = HashMap::new();
1372 invalid_tags.insert(
1373 "test".to_string(),
1374 FinanceEntity::Account(Account {
1375 id: account1.id,
1376 parent: account1.parent,
1377 }),
1378 );
1379
1380 let result = UpdateTransaction::new()
1381 .user_id(user.id)
1382 .transaction_id(tx_id)
1383 .tags(invalid_tags)
1384 .run()
1385 .await;
1386
1387 assert!(
1388 result.is_err(),
1389 "Expected error for invalid entity type in tags"
1390 );
1391 if let Err(CmdError::Args(msg)) = result {
1392 assert!(msg.contains("Invalid entity type in tags"));
1393 } else {
1394 panic!("Expected CmdError::Args with invalid entity type message");
1395 }
1396
1397 let mut conn = user.get_connection().await?;
1399 let initial_split_count = sqlx::query!(
1400 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
1401 tx_id
1402 )
1403 .fetch_one(&mut *conn)
1404 .await?
1405 .count
1406 .unwrap_or(0);
1407
1408 let invalid_account_split = Split {
1410 id: Uuid::new_v4(),
1411 tx_id,
1412 account_id: Uuid::new_v4(), commodity_id,
1414 value_num: -100,
1415 value_denom: 1,
1416 reconcile_state: None,
1417 reconcile_date: None,
1418 lot_id: None,
1419 };
1420 let balancing_split = Split {
1421 id: Uuid::new_v4(),
1422 tx_id,
1423 account_id: account2.id,
1424 commodity_id,
1425 value_num: 100,
1426 value_denom: 1,
1427 reconcile_state: None,
1428 reconcile_date: None,
1429 lot_id: None,
1430 };
1431 let failing_splits = vec![
1432 FinanceEntity::Split(invalid_account_split),
1433 FinanceEntity::Split(balancing_split),
1434 ];
1435
1436 let result = UpdateTransaction::new()
1437 .user_id(user.id)
1438 .transaction_id(tx_id)
1439 .splits(failing_splits)
1440 .run()
1441 .await;
1442
1443 assert!(result.is_err(), "Expected error for non-existent account");
1444
1445 let final_split_count = sqlx::query!(
1447 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
1448 tx_id
1449 )
1450 .fetch_one(&mut *conn)
1451 .await?
1452 .count
1453 .unwrap_or(0);
1454
1455 assert_eq!(
1456 initial_split_count, final_split_count,
1457 "Split count should be unchanged after failed update due to rollback"
1458 );
1459
1460 if let Some(CmdResult::TaggedEntities { entities, .. }) = GetTransaction::new()
1462 .user_id(user.id)
1463 .transaction_id(tx_id)
1464 .run()
1465 .await?
1466 {
1467 assert_eq!(entities.len(), 1, "Expected one transaction");
1468 } else {
1469 panic!("Expected transaction to still exist after failed database operation");
1470 }
1471 }
1472
1473 #[local_db_sqlx_test]
1474 async fn test_update_transaction_prices_and_tags(pool: PgPool) -> anyhow::Result<()> {
1475 let user = USER.get().unwrap();
1476 user.commit()
1477 .await
1478 .expect("Failed to commit user to database");
1479
1480 let commodity_result = CreateCommodity::new()
1482 .symbol("TST".to_string())
1483 .name("Test Commodity".to_string())
1484 .user_id(user.id)
1485 .run()
1486 .await?;
1487
1488 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1489 uuid::Uuid::parse_str(&id)?
1490 } else {
1491 panic!("Expected commodity ID string result");
1492 };
1493
1494 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1496 CreateAccount::new()
1497 .name("Account 1".to_string())
1498 .user_id(user.id)
1499 .run()
1500 .await?
1501 {
1502 account
1503 } else {
1504 panic!("Expected account entity result");
1505 };
1506
1507 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1508 CreateAccount::new()
1509 .name("Account 2".to_string())
1510 .user_id(user.id)
1511 .run()
1512 .await?
1513 {
1514 account
1515 } else {
1516 panic!("Expected account entity result");
1517 };
1518
1519 let tag1_id = Uuid::new_v4();
1521 let tag2_id = Uuid::new_v4();
1522 let mut conn = user.get_connection().await?;
1523
1524 sqlx::query!(
1525 "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
1526 tag1_id,
1527 "category",
1528 "expense",
1529 Some("Expense category".to_string())
1530 )
1531 .execute(&mut *conn)
1532 .await?;
1533
1534 sqlx::query!(
1535 "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
1536 tag2_id,
1537 "project",
1538 "finance_app",
1539 Some("Finance app project".to_string())
1540 )
1541 .execute(&mut *conn)
1542 .await?;
1543
1544 let tx_id = Uuid::new_v4();
1546 let split1 = Split {
1547 id: Uuid::new_v4(),
1548 tx_id,
1549 account_id: account1.id,
1550 commodity_id,
1551 value_num: -100,
1552 value_denom: 1,
1553 reconcile_state: None,
1554 reconcile_date: None,
1555 lot_id: None,
1556 };
1557 let split2 = Split {
1558 id: Uuid::new_v4(),
1559 tx_id,
1560 account_id: account2.id,
1561 commodity_id,
1562 value_num: 100,
1563 value_denom: 1,
1564 reconcile_state: None,
1565 reconcile_date: None,
1566 lot_id: None,
1567 };
1568 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1569 let now = Utc::now();
1570
1571 CreateTransaction::new()
1572 .user_id(user.id)
1573 .splits(splits)
1574 .id(tx_id)
1575 .post_date(now)
1576 .enter_date(now)
1577 .note("Initial transaction".to_string())
1578 .run()
1579 .await?;
1580
1581 let price1 = Price {
1583 id: Uuid::new_v4(),
1584 commodity_id,
1585 currency_id: commodity_id, commodity_split: None, currency_split: None,
1588 date: now,
1589 value_num: 100,
1590 value_denom: 100,
1591 };
1592
1593 let prices = vec![FinanceEntity::Price(price1)];
1594
1595 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1596 UpdateTransaction::new()
1597 .user_id(user.id)
1598 .transaction_id(tx_id)
1599 .prices(prices)
1600 .run()
1601 .await?
1602 {
1603 assert_eq!(updated_tx.id, tx_id);
1604 } else {
1605 panic!("Expected Transaction entity result for price update");
1606 }
1607
1608 let price_count = sqlx::query!(
1610 "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1 AND currency_id = $2",
1611 commodity_id,
1612 commodity_id
1613 )
1614 .fetch_one(&mut *conn)
1615 .await?
1616 .count
1617 .unwrap_or(0);
1618 assert_eq!(price_count, 1, "Expected one price record");
1619
1620 let mut tags = HashMap::new();
1622 tags.insert(
1623 "category".to_string(),
1624 FinanceEntity::Tag(Tag {
1625 id: tag1_id,
1626 tag_name: "category".to_string(),
1627 tag_value: "expense".to_string(),
1628 description: Some("Expense category".to_string()),
1629 }),
1630 );
1631 tags.insert(
1632 "project".to_string(),
1633 FinanceEntity::Tag(Tag {
1634 id: tag2_id,
1635 tag_name: "project".to_string(),
1636 tag_value: "finance_app".to_string(),
1637 description: Some("Finance app project".to_string()),
1638 }),
1639 );
1640
1641 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1642 UpdateTransaction::new()
1643 .user_id(user.id)
1644 .transaction_id(tx_id)
1645 .tags(tags)
1646 .run()
1647 .await?
1648 {
1649 assert_eq!(updated_tx.id, tx_id);
1650 } else {
1651 panic!("Expected Transaction entity result for tag update");
1652 }
1653
1654 let tag_count = sqlx::query!(
1656 "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
1657 tx_id
1658 )
1659 .fetch_one(&mut *conn)
1660 .await?
1661 .count
1662 .unwrap_or(0);
1663 assert_eq!(tag_count, 2, "Expected two tag records");
1664
1665 let new_split1 = Split {
1667 id: Uuid::new_v4(),
1668 tx_id,
1669 account_id: account1.id,
1670 commodity_id,
1671 value_num: -200,
1672 value_denom: 1,
1673 reconcile_state: None,
1674 reconcile_date: None,
1675 lot_id: None,
1676 };
1677 let new_split2 = Split {
1678 id: Uuid::new_v4(),
1679 tx_id,
1680 account_id: account2.id,
1681 commodity_id,
1682 value_num: 200,
1683 value_denom: 1,
1684 reconcile_state: None,
1685 reconcile_date: None,
1686 lot_id: None,
1687 };
1688 let new_splits = vec![
1689 FinanceEntity::Split(new_split1),
1690 FinanceEntity::Split(new_split2),
1691 ];
1692
1693 let new_price = Price {
1694 id: Uuid::new_v4(),
1695 commodity_id,
1696 currency_id: commodity_id,
1697 commodity_split: None,
1698 currency_split: None,
1699 date: now,
1700 value_num: 110,
1701 value_denom: 100,
1702 };
1703 let new_prices = vec![FinanceEntity::Price(new_price)];
1704
1705 let mut new_tags = HashMap::new();
1706 new_tags.insert(
1707 "category".to_string(),
1708 FinanceEntity::Tag(Tag {
1709 id: tag1_id,
1710 tag_name: "category".to_string(),
1711 tag_value: "income".to_string(), description: Some("Expense category".to_string()),
1713 }),
1714 );
1715
1716 if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1717 UpdateTransaction::new()
1718 .user_id(user.id)
1719 .transaction_id(tx_id)
1720 .splits(new_splits)
1721 .prices(new_prices)
1722 .tags(new_tags)
1723 .run()
1724 .await?
1725 {
1726 assert_eq!(updated_tx.id, tx_id);
1727 } else {
1728 panic!("Expected Transaction entity result for combined update");
1729 }
1730
1731 let final_split_count = sqlx::query!(
1733 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
1734 tx_id
1735 )
1736 .fetch_one(&mut *conn)
1737 .await?
1738 .count
1739 .unwrap_or(0);
1740 assert_eq!(final_split_count, 2, "Expected two splits after update");
1741
1742 let final_price_count = sqlx::query!(
1743 "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1",
1744 commodity_id
1745 )
1746 .fetch_one(&mut *conn)
1747 .await?
1748 .count
1749 .unwrap_or(0);
1750 assert_eq!(final_price_count, 2, "Expected two prices after update");
1751
1752 let final_tag_count = sqlx::query!(
1753 "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
1754 tx_id
1755 )
1756 .fetch_one(&mut *conn)
1757 .await?
1758 .count
1759 .unwrap_or(0);
1760 assert_eq!(final_tag_count, 1, "Expected one tag after update");
1761
1762 let mut invalid_tags = HashMap::new();
1764 invalid_tags.insert(
1765 "invalid".to_string(),
1766 FinanceEntity::Tag(Tag {
1767 id: Uuid::new_v4(), tag_name: "invalid".to_string(),
1769 tag_value: "value".to_string(),
1770 description: Some("Invalid tag".to_string()),
1771 }),
1772 );
1773
1774 let result = UpdateTransaction::new()
1775 .user_id(user.id)
1776 .transaction_id(tx_id)
1777 .tags(invalid_tags)
1778 .run()
1779 .await;
1780
1781 assert!(
1782 result.is_err(),
1783 "Expected error for invalid tag with non-existent tag ID"
1784 );
1785
1786 let unchanged_tag_count = sqlx::query!(
1788 "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
1789 tx_id
1790 )
1791 .fetch_one(&mut *conn)
1792 .await?
1793 .count
1794 .unwrap_or(0);
1795 assert_eq!(
1796 unchanged_tag_count, 1,
1797 "Tag count should be unchanged after failed update"
1798 );
1799 }
1800
1801 #[local_db_sqlx_test]
1802 async fn test_delete_transaction_simple(pool: PgPool) -> anyhow::Result<()> {
1803 let user = USER.get().unwrap();
1804 user.commit()
1805 .await
1806 .expect("Failed to commit user to database");
1807
1808 let commodity_result = CreateCommodity::new()
1809 .symbol("TST".to_string())
1810 .name("Test Commodity".to_string())
1811 .user_id(user.id)
1812 .run()
1813 .await?;
1814
1815 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1816 uuid::Uuid::parse_str(&id)?
1817 } else {
1818 panic!("Expected commodity ID string result");
1819 };
1820
1821 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1822 CreateAccount::new()
1823 .name("Account 1".to_string())
1824 .user_id(user.id)
1825 .run()
1826 .await?
1827 {
1828 account
1829 } else {
1830 panic!("Expected account entity result");
1831 };
1832
1833 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1834 CreateAccount::new()
1835 .name("Account 2".to_string())
1836 .user_id(user.id)
1837 .run()
1838 .await?
1839 {
1840 account
1841 } else {
1842 panic!("Expected account entity result");
1843 };
1844
1845 let tx_id = Uuid::new_v4();
1846 let split1 = Split {
1847 id: Uuid::new_v4(),
1848 tx_id,
1849 account_id: account1.id,
1850 commodity_id,
1851 value_num: -100,
1852 value_denom: 1,
1853 reconcile_state: None,
1854 reconcile_date: None,
1855 lot_id: None,
1856 };
1857 let split2 = Split {
1858 id: Uuid::new_v4(),
1859 tx_id,
1860 account_id: account2.id,
1861 commodity_id,
1862 value_num: 100,
1863 value_denom: 1,
1864 reconcile_state: None,
1865 reconcile_date: None,
1866 lot_id: None,
1867 };
1868 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1869 let now = Utc::now();
1870
1871 CreateTransaction::new()
1872 .user_id(user.id)
1873 .splits(splits)
1874 .id(tx_id)
1875 .post_date(now)
1876 .enter_date(now)
1877 .run()
1878 .await?;
1879
1880 let result = DeleteTransaction::new()
1881 .user_id(user.id)
1882 .transaction_id(tx_id)
1883 .run()
1884 .await?;
1885
1886 assert!(result.is_some(), "Expected successful deletion");
1887
1888 let mut conn = user.get_connection().await?;
1889 let tx_exists = sqlx::query!(
1890 "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
1891 tx_id
1892 )
1893 .fetch_one(&mut *conn)
1894 .await?
1895 .count
1896 .unwrap_or(0);
1897 assert_eq!(tx_exists, 0, "Transaction should be deleted");
1898
1899 let splits_exist = sqlx::query!(
1900 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
1901 tx_id
1902 )
1903 .fetch_one(&mut *conn)
1904 .await?
1905 .count
1906 .unwrap_or(0);
1907 assert_eq!(splits_exist, 0, "Splits should be deleted");
1908 }
1909
1910 #[local_db_sqlx_test]
1911 async fn test_delete_transaction_with_tags_and_prices(pool: PgPool) -> anyhow::Result<()> {
1912 let user = USER.get().unwrap();
1913 user.commit()
1914 .await
1915 .expect("Failed to commit user to database");
1916
1917 let commodity_result = CreateCommodity::new()
1918 .symbol("TST".to_string())
1919 .name("Test Commodity".to_string())
1920 .user_id(user.id)
1921 .run()
1922 .await?;
1923
1924 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1925 uuid::Uuid::parse_str(&id)?
1926 } else {
1927 panic!("Expected commodity ID string result");
1928 };
1929
1930 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1931 CreateAccount::new()
1932 .name("Account 1".to_string())
1933 .user_id(user.id)
1934 .run()
1935 .await?
1936 {
1937 account
1938 } else {
1939 panic!("Expected account entity result");
1940 };
1941
1942 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1943 CreateAccount::new()
1944 .name("Account 2".to_string())
1945 .user_id(user.id)
1946 .run()
1947 .await?
1948 {
1949 account
1950 } else {
1951 panic!("Expected account entity result");
1952 };
1953
1954 let tx_id = Uuid::new_v4();
1955 let split1_id = Uuid::new_v4();
1956 let split2_id = Uuid::new_v4();
1957 let split1 = Split {
1958 id: split1_id,
1959 tx_id,
1960 account_id: account1.id,
1961 commodity_id,
1962 value_num: -100,
1963 value_denom: 1,
1964 reconcile_state: None,
1965 reconcile_date: None,
1966 lot_id: None,
1967 };
1968 let split2 = Split {
1969 id: split2_id,
1970 tx_id,
1971 account_id: account2.id,
1972 commodity_id,
1973 value_num: 100,
1974 value_denom: 1,
1975 reconcile_state: None,
1976 reconcile_date: None,
1977 lot_id: None,
1978 };
1979 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1980 let now = Utc::now();
1981
1982 let price = Price {
1983 id: Uuid::new_v4(),
1984 commodity_id,
1985 currency_id: commodity_id,
1986 commodity_split: Some(split1_id),
1987 currency_split: Some(split2_id),
1988 date: now,
1989 value_num: 100,
1990 value_denom: 100,
1991 };
1992
1993 CreateTransaction::new()
1994 .user_id(user.id)
1995 .splits(splits)
1996 .id(tx_id)
1997 .post_date(now)
1998 .enter_date(now)
1999 .prices(vec![FinanceEntity::Price(price)])
2000 .note("Test note".to_string())
2001 .run()
2002 .await?;
2003
2004 let mut conn = user.get_connection().await?;
2005
2006 let tag_count_before = sqlx::query!("SELECT COUNT(*) as count FROM tags")
2007 .fetch_one(&mut *conn)
2008 .await?
2009 .count
2010 .unwrap_or(0);
2011 assert!(tag_count_before > 0, "Should have tags before deletion");
2012
2013 let price_count_before = sqlx::query!(
2014 "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
2015 split1_id,
2016 split2_id
2017 )
2018 .fetch_one(&mut *conn)
2019 .await?
2020 .count
2021 .unwrap_or(0);
2022 assert_eq!(
2023 price_count_before, 1,
2024 "Should have one price before deletion"
2025 );
2026
2027 let result = DeleteTransaction::new()
2028 .user_id(user.id)
2029 .transaction_id(tx_id)
2030 .run()
2031 .await?;
2032
2033 assert!(result.is_some(), "Expected successful deletion");
2034
2035 let tx_exists = sqlx::query!(
2036 "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
2037 tx_id
2038 )
2039 .fetch_one(&mut *conn)
2040 .await?
2041 .count
2042 .unwrap_or(0);
2043 assert_eq!(tx_exists, 0, "Transaction should be deleted");
2044
2045 let splits_exist = sqlx::query!(
2046 "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
2047 tx_id
2048 )
2049 .fetch_one(&mut *conn)
2050 .await?
2051 .count
2052 .unwrap_or(0);
2053 assert_eq!(splits_exist, 0, "Splits should be deleted");
2054
2055 let tx_tags_exist = sqlx::query!(
2056 "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
2057 tx_id
2058 )
2059 .fetch_one(&mut *conn)
2060 .await?
2061 .count
2062 .unwrap_or(0);
2063 assert_eq!(
2064 tx_tags_exist, 0,
2065 "Transaction tags associations should be deleted"
2066 );
2067
2068 let prices_exist = sqlx::query!(
2069 "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
2070 split1_id,
2071 split2_id
2072 )
2073 .fetch_one(&mut *conn)
2074 .await?
2075 .count
2076 .unwrap_or(0);
2077 assert_eq!(prices_exist, 0, "Prices should be deleted");
2078 }
2079
2080 #[local_db_sqlx_test]
2081 async fn test_delete_transaction_nonexistent(pool: PgPool) -> anyhow::Result<()> {
2082 let user = USER.get().unwrap();
2083 user.commit()
2084 .await
2085 .expect("Failed to commit user to database");
2086
2087 let nonexistent_id = Uuid::new_v4();
2088 let result = DeleteTransaction::new()
2089 .user_id(user.id)
2090 .transaction_id(nonexistent_id)
2091 .run()
2092 .await;
2093
2094 assert!(
2095 result.is_err(),
2096 "Expected error for non-existent transaction"
2097 );
2098 if let Err(CmdError::Args(msg)) = result {
2099 assert!(msg.contains("Transaction not found"));
2100 } else {
2101 panic!("Expected CmdError::Args with 'Transaction not found' message");
2102 }
2103 }
2104
2105 #[local_db_sqlx_test]
2106 async fn test_delete_transaction_orphaned_tags(pool: PgPool) -> anyhow::Result<()> {
2107 let user = USER.get().unwrap();
2108 user.commit()
2109 .await
2110 .expect("Failed to commit user to database");
2111
2112 let commodity_result = CreateCommodity::new()
2113 .symbol("TST".to_string())
2114 .name("Test Commodity".to_string())
2115 .user_id(user.id)
2116 .run()
2117 .await?;
2118
2119 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2120 uuid::Uuid::parse_str(&id)?
2121 } else {
2122 panic!("Expected commodity ID string result");
2123 };
2124
2125 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2126 CreateAccount::new()
2127 .name("Account 1".to_string())
2128 .user_id(user.id)
2129 .run()
2130 .await?
2131 {
2132 account
2133 } else {
2134 panic!("Expected account entity result");
2135 };
2136
2137 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2138 CreateAccount::new()
2139 .name("Account 2".to_string())
2140 .user_id(user.id)
2141 .run()
2142 .await?
2143 {
2144 account
2145 } else {
2146 panic!("Expected account entity result");
2147 };
2148
2149 let tx_id = Uuid::new_v4();
2150 let split1 = Split {
2151 id: Uuid::new_v4(),
2152 tx_id,
2153 account_id: account1.id,
2154 commodity_id,
2155 value_num: -100,
2156 value_denom: 1,
2157 reconcile_state: None,
2158 reconcile_date: None,
2159 lot_id: None,
2160 };
2161 let split2 = Split {
2162 id: Uuid::new_v4(),
2163 tx_id,
2164 account_id: account2.id,
2165 commodity_id,
2166 value_num: 100,
2167 value_denom: 1,
2168 reconcile_state: None,
2169 reconcile_date: None,
2170 lot_id: None,
2171 };
2172 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2173 let now = Utc::now();
2174
2175 CreateTransaction::new()
2176 .user_id(user.id)
2177 .splits(splits)
2178 .id(tx_id)
2179 .post_date(now)
2180 .enter_date(now)
2181 .note("Orphaned tag test".to_string())
2182 .run()
2183 .await?;
2184
2185 let mut conn = user.get_connection().await?;
2186
2187 let tag_id = sqlx::query!(
2188 "SELECT tag_id FROM transaction_tags WHERE tx_id = $1",
2189 tx_id
2190 )
2191 .fetch_one(&mut *conn)
2192 .await?
2193 .tag_id;
2194
2195 DeleteTransaction::new()
2196 .user_id(user.id)
2197 .transaction_id(tx_id)
2198 .run()
2199 .await?;
2200
2201 let orphaned_tag_exists =
2202 sqlx::query!("SELECT COUNT(*) as count FROM tags WHERE id = $1", tag_id)
2203 .fetch_one(&mut *conn)
2204 .await?
2205 .count
2206 .unwrap_or(0);
2207 assert_eq!(orphaned_tag_exists, 0, "Orphaned tag should be deleted");
2208 }
2209
2210 const GROCERIES_SCRIPT_WASM: &[u8] =
2211 include_bytes!("../../../web/static/wasm/groceries_markup.wasm");
2212
2213 const TAG_SYNC_SCRIPT_WASM: &[u8] = include_bytes!("../../../web/static/wasm/tag_sync.wasm");
2214
2215 #[local_db_sqlx_test]
2216 async fn test_create_transaction_with_all_scripts_completes(
2217 pool: PgPool,
2218 ) -> anyhow::Result<()> {
2219 let user = USER.get().unwrap();
2220 user.commit()
2221 .await
2222 .expect("Failed to commit user to database");
2223
2224 let mut conn = user.get_connection().await?;
2225
2226 let groceries_script_id = Uuid::new_v4();
2227 sqlx::query_file!(
2228 "sql/insert/scripts/script.sql",
2229 groceries_script_id,
2230 GROCERIES_SCRIPT_WASM
2231 )
2232 .execute(&mut *conn)
2233 .await?;
2234
2235 let tag_sync_script_id = Uuid::new_v4();
2236 sqlx::query_file!(
2237 "sql/insert/scripts/script.sql",
2238 tag_sync_script_id,
2239 TAG_SYNC_SCRIPT_WASM
2240 )
2241 .execute(&mut *conn)
2242 .await?;
2243
2244 let commodity_result = CreateCommodity::new()
2245 .symbol("TST".to_string())
2246 .name("Test Commodity".to_string())
2247 .user_id(user.id)
2248 .run()
2249 .await?;
2250
2251 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2252 uuid::Uuid::parse_str(&id)?
2253 } else {
2254 panic!("Expected commodity ID string result");
2255 };
2256
2257 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2258 CreateAccount::new()
2259 .name("Account 1".to_string())
2260 .user_id(user.id)
2261 .run()
2262 .await?
2263 {
2264 account
2265 } else {
2266 panic!("Expected account entity result");
2267 };
2268
2269 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2270 CreateAccount::new()
2271 .name("Account 2".to_string())
2272 .user_id(user.id)
2273 .run()
2274 .await?
2275 {
2276 account
2277 } else {
2278 panic!("Expected account entity result");
2279 };
2280
2281 let tx_id = Uuid::new_v4();
2282 let split1_id = Uuid::new_v4();
2283 let split2_id = Uuid::new_v4();
2284
2285 let split1 = Split {
2286 id: split1_id,
2287 tx_id,
2288 account_id: account1.id,
2289 commodity_id,
2290 value_num: -5000,
2291 value_denom: 100,
2292 reconcile_state: None,
2293 reconcile_date: None,
2294 lot_id: None,
2295 };
2296
2297 let split2 = Split {
2298 id: split2_id,
2299 tx_id,
2300 account_id: account2.id,
2301 commodity_id,
2302 value_num: 5000,
2303 value_denom: 100,
2304 reconcile_state: None,
2305 reconcile_date: None,
2306 lot_id: None,
2307 };
2308
2309 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2310 let now = Utc::now();
2311
2312 let result = tokio::time::timeout(
2313 std::time::Duration::from_secs(10),
2314 CreateTransaction::new()
2315 .user_id(user.id)
2316 .splits(splits)
2317 .id(tx_id)
2318 .post_date(now)
2319 .enter_date(now)
2320 .note("groceries".to_string())
2321 .run(),
2322 )
2323 .await;
2324
2325 assert!(
2326 result.is_ok(),
2327 "Transaction creation with scripts must complete within 10 seconds"
2328 );
2329 result.unwrap()?;
2330
2331 let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
2332 .fetch_all(&mut *conn)
2333 .await?;
2334
2335 let split1_has_category = split1_tags
2336 .iter()
2337 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2338 assert!(
2339 split1_has_category,
2340 "Split 1 should have category=groceries tag from groceries script"
2341 );
2342
2343 sqlx::query_file!("sql/delete/scripts/by_id.sql", groceries_script_id)
2344 .execute(&mut *conn)
2345 .await?;
2346 sqlx::query_file!("sql/delete/scripts/by_id.sql", tag_sync_script_id)
2347 .execute(&mut *conn)
2348 .await?;
2349 }
2350
2351 #[local_db_sqlx_test]
2352 async fn test_create_transaction_with_script(pool: PgPool) -> anyhow::Result<()> {
2353 let user = USER.get().unwrap();
2354 user.commit()
2355 .await
2356 .expect("Failed to commit user to database");
2357
2358 let mut conn = user.get_connection().await?;
2359
2360 let script_id = Uuid::new_v4();
2362 sqlx::query_file!(
2363 "sql/insert/scripts/script.sql",
2364 script_id,
2365 GROCERIES_SCRIPT_WASM
2366 )
2367 .execute(&mut *conn)
2368 .await?;
2369
2370 let commodity_result = CreateCommodity::new()
2372 .symbol("TST".to_string())
2373 .name("Test Commodity".to_string())
2374 .user_id(user.id)
2375 .run()
2376 .await?;
2377
2378 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2379 uuid::Uuid::parse_str(&id)?
2380 } else {
2381 panic!("Expected commodity ID string result");
2382 };
2383
2384 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2385 CreateAccount::new()
2386 .name("Account 1".to_string())
2387 .user_id(user.id)
2388 .run()
2389 .await?
2390 {
2391 account
2392 } else {
2393 panic!("Expected account entity result");
2394 };
2395
2396 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2397 CreateAccount::new()
2398 .name("Account 2".to_string())
2399 .user_id(user.id)
2400 .run()
2401 .await?
2402 {
2403 account
2404 } else {
2405 panic!("Expected account entity result");
2406 };
2407
2408 let tx_id = Uuid::new_v4();
2409 let split1_id = Uuid::new_v4();
2410 let split2_id = Uuid::new_v4();
2411
2412 let split1 = Split {
2413 id: split1_id,
2414 tx_id,
2415 account_id: account1.id,
2416 commodity_id,
2417 value_num: -5000,
2418 value_denom: 100,
2419 reconcile_state: None,
2420 reconcile_date: None,
2421 lot_id: None,
2422 };
2423
2424 let split2 = Split {
2425 id: split2_id,
2426 tx_id,
2427 account_id: account2.id,
2428 commodity_id,
2429 value_num: 5000,
2430 value_denom: 100,
2431 reconcile_state: None,
2432 reconcile_date: None,
2433 lot_id: None,
2434 };
2435
2436 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2437 let now = Utc::now();
2438
2439 CreateTransaction::new()
2441 .user_id(user.id)
2442 .splits(splits)
2443 .id(tx_id)
2444 .post_date(now)
2445 .enter_date(now)
2446 .note("groceries".to_string())
2447 .run()
2448 .await?;
2449
2450 let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
2452 .fetch_all(&mut *conn)
2453 .await?;
2454
2455 let split2_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split2_id)
2456 .fetch_all(&mut *conn)
2457 .await?;
2458
2459 let split1_has_category = split1_tags
2461 .iter()
2462 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2463 assert!(
2464 split1_has_category,
2465 "Split 1 should have category=groceries tag from script. Tags: {:?}",
2466 split1_tags
2467 .iter()
2468 .map(|t| format!("{}={}", t.tag_name, t.tag_value))
2469 .collect::<Vec<_>>()
2470 );
2471
2472 let split2_has_category = split2_tags
2474 .iter()
2475 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2476 assert!(
2477 split2_has_category,
2478 "Split 2 should have category=groceries tag from script. Tags: {:?}",
2479 split2_tags
2480 .iter()
2481 .map(|t| format!("{}={}", t.tag_name, t.tag_value))
2482 .collect::<Vec<_>>()
2483 );
2484
2485 sqlx::query_file!("sql/delete/scripts/by_id.sql", script_id)
2487 .execute(&mut *conn)
2488 .await?;
2489 }
2490
2491 #[local_db_sqlx_test]
2492 async fn test_create_transaction_script_skips_non_matching(pool: PgPool) -> anyhow::Result<()> {
2493 let user = USER.get().unwrap();
2494 user.commit()
2495 .await
2496 .expect("Failed to commit user to database");
2497
2498 let mut conn = user.get_connection().await?;
2499
2500 let script_id = Uuid::new_v4();
2502 sqlx::query_file!(
2503 "sql/insert/scripts/script.sql",
2504 script_id,
2505 GROCERIES_SCRIPT_WASM
2506 )
2507 .execute(&mut *conn)
2508 .await?;
2509
2510 let commodity_result = CreateCommodity::new()
2512 .symbol("TST".to_string())
2513 .name("Test Commodity".to_string())
2514 .user_id(user.id)
2515 .run()
2516 .await?;
2517
2518 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2519 uuid::Uuid::parse_str(&id)?
2520 } else {
2521 panic!("Expected commodity ID string result");
2522 };
2523
2524 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2525 CreateAccount::new()
2526 .name("Account 1".to_string())
2527 .user_id(user.id)
2528 .run()
2529 .await?
2530 {
2531 account
2532 } else {
2533 panic!("Expected account entity result");
2534 };
2535
2536 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2537 CreateAccount::new()
2538 .name("Account 2".to_string())
2539 .user_id(user.id)
2540 .run()
2541 .await?
2542 {
2543 account
2544 } else {
2545 panic!("Expected account entity result");
2546 };
2547
2548 let tx_id = Uuid::new_v4();
2549 let split1_id = Uuid::new_v4();
2550 let split2_id = Uuid::new_v4();
2551
2552 let split1 = Split {
2553 id: split1_id,
2554 tx_id,
2555 account_id: account1.id,
2556 commodity_id,
2557 value_num: -5000,
2558 value_denom: 100,
2559 reconcile_state: None,
2560 reconcile_date: None,
2561 lot_id: None,
2562 };
2563
2564 let split2 = Split {
2565 id: split2_id,
2566 tx_id,
2567 account_id: account2.id,
2568 commodity_id,
2569 value_num: 5000,
2570 value_denom: 100,
2571 reconcile_state: None,
2572 reconcile_date: None,
2573 lot_id: None,
2574 };
2575
2576 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2577 let now = Utc::now();
2578
2579 CreateTransaction::new()
2581 .user_id(user.id)
2582 .splits(splits)
2583 .id(tx_id)
2584 .post_date(now)
2585 .enter_date(now)
2586 .note("other".to_string())
2587 .run()
2588 .await?;
2589
2590 let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
2592 .fetch_all(&mut *conn)
2593 .await?;
2594
2595 let split2_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split2_id)
2596 .fetch_all(&mut *conn)
2597 .await?;
2598
2599 let split1_has_category = split1_tags
2601 .iter()
2602 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2603 assert!(
2604 !split1_has_category,
2605 "Split 1 should NOT have category tag for non-groceries transaction"
2606 );
2607
2608 let split2_has_category = split2_tags
2609 .iter()
2610 .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2611 assert!(
2612 !split2_has_category,
2613 "Split 2 should NOT have category tag for non-groceries transaction"
2614 );
2615
2616 sqlx::query_file!("sql/delete/scripts/by_id.sql", script_id)
2618 .execute(&mut *conn)
2619 .await?;
2620 }
2621
2622 async fn create_test_transaction(
2623 user: &User,
2624 account1_id: Uuid,
2625 account2_id: Uuid,
2626 commodity_id: Uuid,
2627 post_date: DateTime<Utc>,
2628 amount: i64,
2629 ) -> anyhow::Result<Uuid> {
2630 let tx_id = Uuid::new_v4();
2631 let split1 = Split {
2632 id: Uuid::new_v4(),
2633 tx_id,
2634 account_id: account1_id,
2635 commodity_id,
2636 value_num: -amount,
2637 value_denom: 1,
2638 reconcile_state: None,
2639 reconcile_date: None,
2640 lot_id: None,
2641 };
2642 let split2 = Split {
2643 id: Uuid::new_v4(),
2644 tx_id,
2645 account_id: account2_id,
2646 commodity_id,
2647 value_num: amount,
2648 value_denom: 1,
2649 reconcile_state: None,
2650 reconcile_date: None,
2651 lot_id: None,
2652 };
2653 let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2654
2655 CreateTransaction::new()
2656 .user_id(user.id)
2657 .splits(splits)
2658 .id(tx_id)
2659 .post_date(post_date)
2660 .enter_date(Utc::now())
2661 .run()
2662 .await?;
2663
2664 Ok(tx_id)
2665 }
2666
2667 #[local_db_sqlx_test]
2668 async fn test_pagination_limit(pool: PgPool) -> anyhow::Result<()> {
2669 let user = USER.get().unwrap();
2670 user.commit()
2671 .await
2672 .expect("Failed to commit user to database");
2673
2674 let commodity_result = CreateCommodity::new()
2675 .symbol("TST".to_string())
2676 .name("Test Commodity".to_string())
2677 .user_id(user.id)
2678 .run()
2679 .await?;
2680
2681 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2682 uuid::Uuid::parse_str(&id)?
2683 } else {
2684 panic!("Expected commodity ID string result");
2685 };
2686
2687 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2688 CreateAccount::new()
2689 .name("Account 1".to_string())
2690 .user_id(user.id)
2691 .run()
2692 .await?
2693 {
2694 account
2695 } else {
2696 panic!("Expected account entity result");
2697 };
2698
2699 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2700 CreateAccount::new()
2701 .name("Account 2".to_string())
2702 .user_id(user.id)
2703 .run()
2704 .await?
2705 {
2706 account
2707 } else {
2708 panic!("Expected account entity result");
2709 };
2710
2711 let base_time = Utc::now();
2712 for i in 0..25 {
2713 let post_date = base_time - Duration::days(i);
2714 create_test_transaction(
2715 user,
2716 account1.id,
2717 account2.id,
2718 commodity_id,
2719 post_date,
2720 100 + i,
2721 )
2722 .await?;
2723 }
2724
2725 if let Some(CmdResult::TaggedEntities {
2727 entities,
2728 pagination: Some(pagination),
2729 }) = ListTransactions::new()
2730 .user_id(user.id)
2731 .limit(10)
2732 .run()
2733 .await?
2734 {
2735 assert_eq!(entities.len(), 10, "Expected exactly 10 transactions");
2736 assert_eq!(pagination.total_count, 25);
2737 assert_eq!(pagination.limit, 10);
2738 assert_eq!(pagination.offset, 0);
2739 assert!(pagination.has_more);
2740 } else {
2741 panic!("Expected TaggedEntities result with pagination");
2742 }
2743
2744 if let Some(CmdResult::TaggedEntities {
2746 entities,
2747 pagination: Some(pagination),
2748 }) = ListTransactions::new()
2749 .user_id(user.id)
2750 .limit(5)
2751 .run()
2752 .await?
2753 {
2754 assert_eq!(entities.len(), 5, "Expected exactly 5 transactions");
2755 assert_eq!(pagination.total_count, 25);
2756 assert!(pagination.has_more);
2757 } else {
2758 panic!("Expected TaggedEntities result with pagination");
2759 }
2760
2761 if let Some(CmdResult::TaggedEntities {
2763 entities,
2764 pagination: Some(pagination),
2765 }) = ListTransactions::new()
2766 .user_id(user.id)
2767 .limit(100)
2768 .run()
2769 .await?
2770 {
2771 assert_eq!(entities.len(), 25, "Expected all 25 transactions");
2772 assert_eq!(pagination.total_count, 25);
2773 assert!(!pagination.has_more);
2774 } else {
2775 panic!("Expected TaggedEntities result with pagination");
2776 }
2777 }
2778
2779 #[local_db_sqlx_test]
2780 async fn test_pagination_offset(pool: PgPool) -> anyhow::Result<()> {
2781 let user = USER.get().unwrap();
2782 user.commit()
2783 .await
2784 .expect("Failed to commit user to database");
2785
2786 let commodity_result = CreateCommodity::new()
2787 .symbol("TST".to_string())
2788 .name("Test Commodity".to_string())
2789 .user_id(user.id)
2790 .run()
2791 .await?;
2792
2793 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2794 uuid::Uuid::parse_str(&id)?
2795 } else {
2796 panic!("Expected commodity ID string result");
2797 };
2798
2799 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2800 CreateAccount::new()
2801 .name("Account 1".to_string())
2802 .user_id(user.id)
2803 .run()
2804 .await?
2805 {
2806 account
2807 } else {
2808 panic!("Expected account entity result");
2809 };
2810
2811 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2812 CreateAccount::new()
2813 .name("Account 2".to_string())
2814 .user_id(user.id)
2815 .run()
2816 .await?
2817 {
2818 account
2819 } else {
2820 panic!("Expected account entity result");
2821 };
2822
2823 let base_time = Utc::now();
2824 for i in 0..25 {
2825 let post_date = base_time - Duration::days(i);
2826 create_test_transaction(
2827 user,
2828 account1.id,
2829 account2.id,
2830 commodity_id,
2831 post_date,
2832 100 + i,
2833 )
2834 .await?;
2835 }
2836
2837 if let Some(CmdResult::TaggedEntities {
2839 entities,
2840 pagination: Some(pagination),
2841 }) = ListTransactions::new()
2842 .user_id(user.id)
2843 .limit(10)
2844 .offset(10)
2845 .run()
2846 .await?
2847 {
2848 assert_eq!(
2849 entities.len(),
2850 10,
2851 "Expected 10 transactions on second page"
2852 );
2853 assert_eq!(pagination.total_count, 25);
2854 assert_eq!(pagination.offset, 10);
2855 assert!(pagination.has_more);
2856 } else {
2857 panic!("Expected TaggedEntities result with pagination");
2858 }
2859
2860 if let Some(CmdResult::TaggedEntities {
2862 entities,
2863 pagination: Some(pagination),
2864 }) = ListTransactions::new()
2865 .user_id(user.id)
2866 .limit(10)
2867 .offset(20)
2868 .run()
2869 .await?
2870 {
2871 assert_eq!(entities.len(), 5, "Expected 5 transactions on last page");
2872 assert_eq!(pagination.total_count, 25);
2873 assert!(!pagination.has_more);
2874 } else {
2875 panic!("Expected TaggedEntities result with pagination");
2876 }
2877
2878 if let Some(CmdResult::TaggedEntities {
2880 entities,
2881 pagination: Some(pagination),
2882 }) = ListTransactions::new()
2883 .user_id(user.id)
2884 .limit(10)
2885 .offset(100)
2886 .run()
2887 .await?
2888 {
2889 assert!(entities.is_empty(), "Expected no transactions beyond total");
2890 assert_eq!(pagination.total_count, 25);
2891 assert!(!pagination.has_more);
2892 } else {
2893 panic!("Expected TaggedEntities result with pagination");
2894 }
2895 }
2896
2897 #[local_db_sqlx_test]
2898 async fn test_pagination_date_filter(pool: PgPool) -> anyhow::Result<()> {
2899 let user = USER.get().unwrap();
2900 user.commit()
2901 .await
2902 .expect("Failed to commit user to database");
2903
2904 let commodity_result = CreateCommodity::new()
2905 .symbol("TST".to_string())
2906 .name("Test Commodity".to_string())
2907 .user_id(user.id)
2908 .run()
2909 .await?;
2910
2911 let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2912 uuid::Uuid::parse_str(&id)?
2913 } else {
2914 panic!("Expected commodity ID string result");
2915 };
2916
2917 let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2918 CreateAccount::new()
2919 .name("Account 1".to_string())
2920 .user_id(user.id)
2921 .run()
2922 .await?
2923 {
2924 account
2925 } else {
2926 panic!("Expected account entity result");
2927 };
2928
2929 let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2930 CreateAccount::new()
2931 .name("Account 2".to_string())
2932 .user_id(user.id)
2933 .run()
2934 .await?
2935 {
2936 account
2937 } else {
2938 panic!("Expected account entity result");
2939 };
2940
2941 let base_time = Utc::now();
2942 for i in 0..30 {
2943 let post_date = base_time - Duration::days(i);
2944 create_test_transaction(
2945 user,
2946 account1.id,
2947 account2.id,
2948 commodity_id,
2949 post_date,
2950 100 + i,
2951 )
2952 .await?;
2953 }
2954
2955 let date_from = base_time - Duration::days(9);
2957 if let Some(CmdResult::TaggedEntities {
2958 entities,
2959 pagination: Some(pagination),
2960 }) = ListTransactions::new()
2961 .user_id(user.id)
2962 .date_from(date_from)
2963 .run()
2964 .await?
2965 {
2966 assert_eq!(
2967 entities.len(),
2968 10,
2969 "Expected 10 transactions from last 10 days"
2970 );
2971 assert_eq!(pagination.total_count, 10);
2972 } else {
2973 panic!("Expected TaggedEntities result with pagination");
2974 }
2975
2976 let date_to = base_time - Duration::days(20);
2978 if let Some(CmdResult::TaggedEntities {
2979 entities,
2980 pagination: Some(pagination),
2981 }) = ListTransactions::new()
2982 .user_id(user.id)
2983 .date_to(date_to)
2984 .run()
2985 .await?
2986 {
2987 assert_eq!(
2988 entities.len(),
2989 10,
2990 "Expected 10 transactions older than 20 days"
2991 );
2992 assert_eq!(pagination.total_count, 10);
2993 } else {
2994 panic!("Expected TaggedEntities result with pagination");
2995 }
2996
2997 let date_from = base_time - Duration::days(19);
2999 let date_to = base_time - Duration::days(10);
3000 if let Some(CmdResult::TaggedEntities {
3001 entities,
3002 pagination: Some(pagination),
3003 }) = ListTransactions::new()
3004 .user_id(user.id)
3005 .date_from(date_from)
3006 .date_to(date_to)
3007 .run()
3008 .await?
3009 {
3010 assert_eq!(entities.len(), 10, "Expected 10 transactions in date range");
3011 assert_eq!(pagination.total_count, 10);
3012 } else {
3013 panic!("Expected TaggedEntities result with pagination");
3014 }
3015
3016 let date_from = base_time - Duration::days(29);
3018 if let Some(CmdResult::TaggedEntities {
3019 entities,
3020 pagination: Some(pagination),
3021 }) = ListTransactions::new()
3022 .user_id(user.id)
3023 .date_from(date_from)
3024 .limit(5)
3025 .run()
3026 .await?
3027 {
3028 assert_eq!(entities.len(), 5, "Expected 5 transactions with limit");
3029 assert_eq!(pagination.total_count, 30);
3030 assert!(pagination.has_more);
3031 } else {
3032 panic!("Expected TaggedEntities result with pagination");
3033 }
3034 }
3035}