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