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