Skip to main content

server/command/
transaction.rs

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        // Common ticket operations
128        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        // Validate new splits if provided (before making any changes)
333        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                    // Validate split belongs to this transaction
340                    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            // Ensure splits sum to zero (double-entry bookkeeping requirement)
355            // For multi-currency transactions, individual currencies won't sum to zero
356            // (they're balanced via the price table), so only validate single-currency
357            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        // Update transaction metadata first
367        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        // Process splits update atomically: delete then insert
377        if let Some(new_splits) = splits {
378            // Delete existing split_tags, splits and their associated prices
379            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            // Insert new splits
392            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        // Validate and process prices if provided
413        if let Some(ref new_prices) = prices {
414            for entity in new_prices {
415                if let FinanceEntity::Price(_) = entity {
416                    // Price validation could be added here
417                } else {
418                    return Err(CmdError::Args("Invalid entity type in prices".to_string()));
419                }
420            }
421        }
422
423        // Validate tags if provided
424        if let Some(ref new_tags) = tags {
425            for entity in new_tags.values() {
426                if let FinanceEntity::Tag(_) = entity {
427                    // Tag validation could be added here
428                } else {
429                    return Err(CmdError::Args("Invalid entity type in tags".to_string()));
430                }
431            }
432        }
433
434        // Process prices update atomically
435        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        // Process tags update atomically: delete then insert
456        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        // Handle note field by creating/updating note tag
475        if let Some(note_value) = note {
476            // First delete any existing note tag for this transaction
477            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    /// Context for keeping environment intact
597    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        // First create a commodity
622        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        // Get the commodity ID
630        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        // Create two accounts
637        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        // Create splits
664        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        // Create transaction with splits
683        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            // Verify splits were created
698            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        // First create a commodity
740        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        // Get the commodity ID
748        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        // Create two accounts
755        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        // Create a transaction between the accounts
780        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        // List all transactions
818        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        // List transactions filtered by account
837        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        // List transactions for non-existent account
849        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        // First create a commodity
873        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        // Create two accounts
887        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        // Create a transaction
912        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        // Test GetTransaction
949        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        // Test GetTransaction with non-existent ID
967        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        // First create a commodity
986        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        // Create two accounts
1000        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        // Create a transaction
1025        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        // Test UpdateTransaction with only note change
1062        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        // Test UpdateTransaction with new splits
1077        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        // Test UpdateTransaction atomicity: unbalanced splits should fail
1118        let unbalanced_split1 = Split {
1119            id: Uuid::new_v4(),
1120            tx_id,
1121            account_id: account1.id,
1122            commodity_id,
1123            value_num: -100, // This doesn't balance with split2
1124            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, // Should be 100 to balance
1135            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        // Verify original transaction is unchanged after failed update
1154        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            // Transaction should still exist and be unchanged
1162        } else {
1163            panic!("Expected transaction to still exist after failed update");
1164        }
1165
1166        // Test UpdateTransaction with non-existent transaction
1167        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        // First create a commodity
1187        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        // Create two accounts
1201        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        // Create a transaction
1226        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        // Test 1: Split transaction ID mismatch validation
1263        let wrong_tx_id = Uuid::new_v4();
1264        let invalid_split = Split {
1265            id: Uuid::new_v4(),
1266            tx_id: wrong_tx_id, // Wrong transaction ID
1267            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        // Verify original transaction is unchanged
1309        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        // Test 2: Invalid entity type in splits
1321        let invalid_splits = vec![
1322            FinanceEntity::Account(Account {
1323                id: account1.id,
1324                parent: account1.parent,
1325            }), // Wrong entity type
1326        ];
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        // Test 3: Invalid entity type in prices
1346        let invalid_prices = vec![
1347            FinanceEntity::Account(Account {
1348                id: account1.id,
1349                parent: account1.parent,
1350            }), // Wrong entity type
1351        ];
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        // Test 4: Invalid entity type in tags
1371        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        // Test 5: Database rollback verification - count splits before and after failed update
1398        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        // Try an update that will fail during split insertion (invalid account ID)
1409        let invalid_account_split = Split {
1410            id: Uuid::new_v4(),
1411            tx_id,
1412            account_id: Uuid::new_v4(), // Non-existent account
1413            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        // Verify splits count is unchanged (rollback occurred)
1446        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        // Verify original transaction is still intact
1461        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        // Create a simple commodity for testing
1481        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        // Create accounts
1495        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        // Create tags for testing manually
1520        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        // Create initial transaction with simple same-commodity splits
1545        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        // Test 1: Update transaction with standalone prices (not linked to splits)
1582        let price1 = Price {
1583            id: Uuid::new_v4(),
1584            commodity_id,
1585            currency_id: commodity_id, // Same commodity for simplicity
1586            commodity_split: None,     // Not linked to specific splits
1587            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        // Verify prices were inserted
1609        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        // Test 2: Update transaction with tags
1621        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        // Verify tags were inserted
1655        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        // Test 3: Combined update (new splits, new prices, and new tags)
1666        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(), // Changed value
1712                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        // Verify all updates were applied atomically
1732        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        // Test 4: Tag validation failure with non-existent tag
1763        let mut invalid_tags = HashMap::new();
1764        invalid_tags.insert(
1765            "invalid".to_string(),
1766            FinanceEntity::Tag(Tag {
1767                id: Uuid::new_v4(), // Non-existent tag
1768                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        // Verify original tags are unchanged after failed tag update
1787        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        // Insert the groceries script into the database
2361        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        // Create commodity and accounts
2371        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        // Create transaction with note="groceries" - this should trigger the script
2440        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        // Verify that the script added "category=groceries" tags to both splits
2451        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        // Check split1 has the category tag
2460        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        // Check split2 has the category tag
2473        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        // Clean up the script
2486        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        // Insert the groceries script into the database
2501        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        // Create commodity and accounts
2511        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        // Create transaction with note="other" - this should NOT trigger the script
2580        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        // Verify that the script did NOT add any tags to splits
2591        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        // Neither split should have the category tag
2600        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        // Clean up the script
2617        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        // Test limit=10 returns exactly 10 transactions
2726        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        // Test limit=5 returns exactly 5 transactions
2745        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        // Test limit=100 returns all 25 transactions (limit > total)
2762        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        // Test offset=10, limit=10 returns second page
2838        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        // Test offset=20, limit=10 returns last page (only 5 remaining)
2861        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        // Test offset beyond total returns empty
2879        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        // Test date_from filter (last 10 days)
2956        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        // Test date_to filter (older than 20 days)
2977        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        // Test date range filter (days 10-19)
2998        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        // Test date range with pagination
3017        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}