1
use cfg_if::cfg_if;
2
#[cfg(not(feature = "scripting"))]
3
use finance::{price::Price, split::Split};
4
use finance::{tag::Tag, transaction::Transaction};
5
#[cfg(feature = "scripting")]
6
use scripting::ScriptExecutor;
7
use sqlx::{
8
    Acquire,
9
    types::Uuid,
10
    types::chrono::{DateTime, Utc},
11
};
12
use std::{collections::HashMap, fmt::Debug};
13
use supp_macro::command;
14

            
15
#[cfg(feature = "scripting")]
16
use crate::script::TransactionState;
17
use crate::{config::ConfigError, user::User};
18

            
19
use super::{CmdError, CmdResult, FinanceEntity, PaginationInfo};
20

            
21
command! {
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
30
        let mut conn = user.get_connection().await.map_err(|err| {
45
30
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
46
30
            ConfigError::DB
47
30
        })?;
48

            
49
        let tx = Transaction {
50
            id,
51
            post_date,
52
            enter_date,
53
        };
54

            
55
        cfg_if! {
56
            if #[cfg(feature = "scripting")] {
57
                let (transaction, splits, prices, transaction_tags, split_tags) = {
58
                    let scripts: Vec<(Uuid, Vec<u8>)> = sqlx::query_file!("sql/select/scripts/enabled.sql")
59
                        .fetch_all(&mut *conn)
60
                        .await?
61
                        .into_iter()
62
4
                        .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
3
                        tokio::task::spawn_blocking(move || {
75
3
                            let executor = ScriptExecutor::new();
76
3
                            state.run_scripts(&executor, &scripts)
77
3
                        })
78
                        .await
79
                        .map_err(|e| CmdError::Script(format!("{e:?}")))?
80
                        .map_err(|e| {
81
                            log::error!("{}", t!("Script execution failed: %{err}", err = e : {:?}));
82
                            CmdError::Script(format!("{e:?}"))
83
                        })?
84
                    };
85

            
86
                    (state.transaction, state.splits, state.prices, state.transaction_tags, state.split_tags)
87
                };
88
            } else {
89
                let (transaction, splits, prices, transaction_tags, split_tags) = {
90
                    let splits: Vec<Split> = splits
91
                        .into_iter()
92
                        .filter_map(|e| match e {
93
                            FinanceEntity::Split(s) => Some(s),
94
                            _ => None,
95
                        })
96
                        .collect();
97

            
98
                    let prices: Vec<Price> = prices
99
                        .map(|ps| {
100
                            ps.into_iter()
101
                                .filter_map(|e| match e {
102
                                    FinanceEntity::Price(p) => Some(p),
103
                                    _ => None,
104
                                })
105
                                .collect()
106
                        })
107
                        .unwrap_or_default();
108

            
109
                    let transaction_tags: Vec<Tag> = note
110
                        .map(|n| {
111
                            vec![Tag {
112
                                id: Uuid::new_v4(),
113
                                tag_name: "note".to_string(),
114
                                tag_value: n,
115
                                description: None,
116
                            }]
117
                        })
118
                        .unwrap_or_default();
119

            
120
                    let split_tags: Vec<(Uuid, Tag)> = split_tags.unwrap_or_default();
121

            
122
                    (tx, splits, prices, transaction_tags, split_tags)
123
                };
124
            }
125
        }
126

            
127
        // Common ticket operations
128
        let mut ticket = transaction.enter(&mut *conn).await?;
129

            
130
        let split_refs: Vec<_> = splits.iter().collect();
131
        ticket.add_splits(&split_refs).await?;
132

            
133
        if !prices.is_empty() {
134
            let price_refs: Vec<_> = prices.iter().collect();
135
            ticket.add_conversions(&price_refs).await?;
136
        }
137

            
138
        if !transaction_tags.is_empty() {
139
            let tag_refs: Vec<_> = transaction_tags.iter().collect();
140
            ticket.add_tags(&tag_refs).await?;
141
        }
142

            
143
        if !split_tags.is_empty() {
144
            ticket.add_split_tags(&split_tags).await?;
145
        }
146

            
147
        ticket.commit().await?;
148

            
149
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(transaction))))
150
    }
151
1418
}
152

            
153
command! {
154
    ListTransactions {
155
        #[required]
156
        user_id: Uuid,
157
        #[optional]
158
        account: Uuid,
159
        #[optional]
160
        limit: i64,
161
        #[optional]
162
        offset: i64,
163
        #[optional]
164
        date_from: DateTime<Utc>,
165
        #[optional]
166
        date_to: DateTime<Utc>,
167
    } => {
168
        let user = User { id: user_id };
169
110
        let mut conn = user.get_connection().await.map_err(|err| {
170
110
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
171
110
            ConfigError::DB
172
110
        })?;
173

            
174
        let account_uuid = account.as_ref();
175
        let effective_limit = limit.unwrap_or(20);
176
        let effective_offset = offset.unwrap_or(0);
177
        let date_from_ref = date_from.as_ref();
178
        let date_to_ref = date_to.as_ref();
179

            
180
        let count_result = sqlx::query_file!(
181
            "sql/count/transactions/filtered.sql",
182
            account_uuid,
183
            date_from_ref,
184
            date_to_ref
185
        )
186
        .fetch_one(&mut *conn)
187
        .await?;
188

            
189
        let total_count = count_result.count.unwrap_or(0);
190

            
191
        let transactions = sqlx::query_file!(
192
            "sql/select/transactions/paginated.sql",
193
            account_uuid,
194
            date_from_ref,
195
            date_to_ref,
196
            effective_limit,
197
            effective_offset
198
        )
199
        .fetch_all(&mut *conn)
200
        .await?;
201

            
202
        let mut tagged_transactions = Vec::new();
203
        for tx_row in transactions {
204
            let transaction = Transaction {
205
                id: tx_row.id,
206
                post_date: tx_row.post_date,
207
                enter_date: tx_row.enter_date,
208
            };
209

            
210
            let tags: HashMap<String, FinanceEntity> =
211
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
212
                    .fetch_all(&mut *conn)
213
                    .await?
214
                    .into_iter()
215
                    .map(|row| {
216
                        (
217
                            row.tag_name.clone(),
218
                            FinanceEntity::Tag(Tag {
219
                                id: row.id,
220
                                tag_name: row.tag_name,
221
                                tag_value: row.tag_value,
222
                                description: row.description,
223
                            }),
224
                        )
225
                    })
226
                    .collect();
227

            
228
            tagged_transactions.push((FinanceEntity::Transaction(transaction), tags));
229
        }
230

            
231
        let pagination = PaginationInfo {
232
            total_count,
233
            limit: effective_limit,
234
            offset: effective_offset,
235
            has_more: effective_offset + (tagged_transactions.len() as i64) < total_count,
236
        };
237

            
238
        Ok(Some(CmdResult::TaggedEntities {
239
            entities: tagged_transactions,
240
            pagination: Some(pagination),
241
        }))
242
    }
243
799
}
244

            
245
command! {
246
    GetTransaction {
247
        #[required]
248
        user_id: Uuid,
249
        #[required]
250
        transaction_id: Uuid,
251
    } => {
252
        let user = User { id: user_id };
253
        let mut conn = user.get_connection().await.map_err(|err| {
254
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
255
            ConfigError::DB
256
        })?;
257

            
258
        let tx_row = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
259
            .fetch_optional(&mut *conn)
260
            .await?;
261

            
262
        if let Some(tx_row) = tx_row {
263
            let transaction = Transaction {
264
                id: tx_row.id,
265
                post_date: tx_row.post_date,
266
                enter_date: tx_row.enter_date,
267
            };
268

            
269
            let tags: HashMap<String, FinanceEntity> =
270
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
271
                    .fetch_all(&mut *conn)
272
                    .await?
273
                    .into_iter()
274
4
                    .map(|row| {
275
4
                        (
276
4
                            row.tag_name.clone(),
277
4
                            FinanceEntity::Tag(Tag {
278
4
                                id: row.id,
279
4
                                tag_name: row.tag_name,
280
4
                                tag_value: row.tag_value,
281
4
                                description: row.description,
282
4
                            }),
283
4
                        )
284
4
                    })
285
                    .collect();
286

            
287
            Ok(Some(CmdResult::TaggedEntities {
288
                entities: vec![(FinanceEntity::Transaction(transaction), tags)],
289
                pagination: None,
290
            }))
291
        } else {
292
            Ok(None)
293
        }
294
    }
295
20
}
296

            
297
command! {
298
    UpdateTransaction {
299
        #[required]
300
        user_id: Uuid,
301
        #[required]
302
        transaction_id: Uuid,
303
        #[optional]
304
        splits: Vec<FinanceEntity>,
305
        #[optional]
306
        post_date: DateTime<Utc>,
307
        #[optional]
308
        enter_date: DateTime<Utc>,
309
        #[optional]
310
        note: String,
311
        #[optional]
312
        prices: Vec<FinanceEntity>,
313
        #[optional]
314
        tags: HashMap<String, FinanceEntity>,
315
    } => {
316
        let user = User { id: user_id };
317
        let mut conn = user.get_connection().await.map_err(|err| {
318
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
319
            ConfigError::DB
320
        })?;
321

            
322
        let mut tx = conn.begin().await?;
323

            
324
        let existing = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
325
            .fetch_optional(&mut *tx)
326
            .await?
327
1
            .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
328

            
329
        let final_post_date = post_date.unwrap_or(existing.post_date);
330
        let final_enter_date = enter_date.unwrap_or(existing.enter_date);
331

            
332
        // Validate new splits if provided (before making any changes)
333
        if let Some(ref new_splits) = splits {
334
            let mut commodity_sums: std::collections::HashMap<Uuid, num_rational::Rational64> =
335
                std::collections::HashMap::new();
336

            
337
            for entity in new_splits {
338
                if let FinanceEntity::Split(split) = entity {
339
                    // Validate split belongs to this transaction
340
                    if split.tx_id != transaction_id {
341
                        return Err(CmdError::Args("Split transaction ID mismatch".to_string()));
342
                    }
343

            
344
                    let split_value =
345
                        num_rational::Rational64::new(split.value_num, split.value_denom);
346
                    *commodity_sums.entry(split.commodity_id).or_insert(
347
                        num_rational::Rational64::new(0, 1),
348
                    ) += split_value;
349
                } else {
350
                    return Err(CmdError::Args("Invalid entity type in splits".to_string()));
351
                }
352
            }
353

            
354
            // Ensure splits sum to zero (double-entry bookkeeping requirement)
355
            // For multi-currency transactions, individual currencies won't sum to zero
356
            // (they're balanced via the price table), so only validate single-currency
357
            if commodity_sums.len() == 1 {
358
                for sum in commodity_sums.values() {
359
                    if *sum != num_rational::Rational64::new(0, 1) {
360
                        return Err(CmdError::Args("Splits must sum to zero".to_string()));
361
                    }
362
                }
363
            }
364
        }
365

            
366
        // Update transaction metadata first
367
        sqlx::query_file!(
368
            "sql/update/transactions/update.sql",
369
            transaction_id,
370
            final_post_date,
371
            final_enter_date
372
        )
373
        .execute(&mut *tx)
374
        .await?;
375

            
376
        // Process splits update atomically: delete then insert
377
        if let Some(new_splits) = splits {
378
            // Delete existing split_tags, splits and their associated prices
379
            sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
380
                .execute(&mut *tx)
381
                .await?;
382

            
383
            sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
384
                .execute(&mut *tx)
385
                .await?;
386

            
387
            sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
388
                .execute(&mut *tx)
389
                .await?;
390

            
391
            // Insert new splits
392
            for entity in new_splits {
393
                if let FinanceEntity::Split(split) = entity {
394
                    sqlx::query_file!(
395
                        "sql/insert/splits/split.sql",
396
                        split.id,
397
                        split.tx_id,
398
                        split.account_id,
399
                        split.commodity_id,
400
                        split.reconcile_state,
401
                        split.reconcile_date,
402
                        split.value_num,
403
                        split.value_denom,
404
                        split.lot_id
405
                    )
406
                    .execute(&mut *tx)
407
                    .await?;
408
                }
409
            }
410
        }
411

            
412
        // Validate and process prices if provided
413
        if let Some(ref new_prices) = prices {
414
            for entity in new_prices {
415
                if let FinanceEntity::Price(_) = entity {
416
                    // Price validation could be added here
417
                } else {
418
                    return Err(CmdError::Args("Invalid entity type in prices".to_string()));
419
                }
420
            }
421
        }
422

            
423
        // Validate tags if provided
424
        if let Some(ref new_tags) = tags {
425
            for entity in new_tags.values() {
426
                if let FinanceEntity::Tag(_) = entity {
427
                    // Tag validation could be added here
428
                } else {
429
                    return Err(CmdError::Args("Invalid entity type in tags".to_string()));
430
                }
431
            }
432
        }
433

            
434
        // Process prices update atomically
435
        if let Some(new_prices) = prices {
436
            for entity in new_prices {
437
                if let FinanceEntity::Price(price) = entity {
438
                    sqlx::query_file!(
439
                        "sql/insert/prices/price.sql",
440
                        price.id,
441
                        price.commodity_id,
442
                        price.currency_id,
443
                        price.commodity_split,
444
                        price.currency_split,
445
                        price.date,
446
                        price.value_num,
447
                        price.value_denom
448
                    )
449
                    .execute(&mut *tx)
450
                    .await?;
451
                }
452
            }
453
        }
454

            
455
        // Process tags update atomically: delete then insert
456
        if let Some(new_tags) = tags {
457
            sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
458
                .execute(&mut *tx)
459
                .await?;
460

            
461
            for (_, entity) in new_tags {
462
                if let FinanceEntity::Tag(tag) = entity {
463
                    sqlx::query_file!(
464
                        "sql/insert/transaction_tags/transaction_tag.sql",
465
                        transaction_id,
466
                        tag.id
467
                    )
468
                    .execute(&mut *tx)
469
                    .await?;
470
                }
471
            }
472
        }
473

            
474
        // Handle note field by creating/updating note tag
475
        if let Some(note_value) = note {
476
            // First delete any existing note tag for this transaction
477
            sqlx::query!("DELETE FROM transaction_tags WHERE tx_id = $1 AND tag_id IN (SELECT id FROM tags WHERE tag_name = 'note')", transaction_id)
478
                .execute(&mut *tx)
479
                .await?;
480

            
481
            if !note_value.trim().is_empty() {
482
                let note_tag_id = Tag {
483
                    id: Uuid::new_v4(),
484
                    tag_name: "note".to_string(),
485
                    tag_value: note_value,
486
                    description: None,
487
                }
488
                .commit(&mut *tx)
489
                .await?;
490

            
491
                sqlx::query_file!(
492
                    "sql/insert/transaction_tags/transaction_tag.sql",
493
                    transaction_id,
494
                    note_tag_id
495
                )
496
                .execute(&mut *tx)
497
                .await?;
498
            }
499
        }
500

            
501
        tx.commit().await?;
502

            
503
        let updated_transaction = Transaction {
504
            id: transaction_id,
505
            post_date: final_post_date,
506
            enter_date: final_enter_date,
507
        };
508

            
509
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(updated_transaction))))
510
    }
511
67
}
512

            
513
command! {
514
    DeleteTransaction {
515
        #[required]
516
        user_id: Uuid,
517
        #[required]
518
        transaction_id: Uuid,
519
    } => {
520
        let user = User { id: user_id };
521
        let mut conn = user.get_connection().await.map_err(|err| {
522
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
523
            ConfigError::DB
524
        })?;
525

            
526
        sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
527
            .fetch_optional(&mut *conn)
528
            .await?
529
1
            .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
530

            
531
        let mut tx = conn.begin().await?;
532

            
533
        let tag_ids_to_check: Vec<Uuid> = sqlx::query_file!(
534
            "sql/select/tags/by_transaction_and_splits.sql",
535
            transaction_id
536
        )
537
        .fetch_all(&mut *tx)
538
        .await?
539
        .into_iter()
540
        .filter_map(|row| row.tag_id)
541
        .collect();
542

            
543
        sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
544
            .execute(&mut *tx)
545
            .await?;
546

            
547
        sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
548
            .execute(&mut *tx)
549
            .await?;
550

            
551
        sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
552
            .execute(&mut *tx)
553
            .await?;
554

            
555
        for tag_id in tag_ids_to_check {
556
            let is_orphaned = sqlx::query_file!("sql/check/tags/is_orphaned.sql", tag_id)
557
                .fetch_one(&mut *tx)
558
                .await?
559
                .is_orphaned
560
                .unwrap_or(false);
561

            
562
            if is_orphaned {
563
                sqlx::query_file!("sql/delete/tags/by_id.sql", tag_id)
564
                    .execute(&mut *tx)
565
                    .await?;
566
            }
567
        }
568

            
569
        sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
570
            .execute(&mut *tx)
571
            .await?;
572

            
573
        sqlx::query_file!("sql/delete/transactions/by_id.sql", transaction_id)
574
            .execute(&mut *tx)
575
            .await?;
576

            
577
        tx.commit().await?;
578

            
579
        Ok(Some(CmdResult::String("Transaction deleted successfully".to_string())))
580
    }
581
16
}
582

            
583
#[cfg(test)]
584
mod command_tests {
585
    use super::*;
586
    use crate::{
587
        command::{account::CreateAccount, commodity::CreateCommodity},
588
        db::DB_POOL,
589
    };
590
    use chrono::Duration;
591
    use finance::{account::Account, price::Price, split::Split};
592
    use sqlx::PgPool;
593
    use supp_macro::local_db_sqlx_test;
594
    use tokio::sync::OnceCell;
595

            
596
    /// Context for keeping environment intact
597
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
598
    static USER: OnceCell<User> = OnceCell::const_new();
599

            
600
17
    async fn setup() {
601
17
        CONTEXT
602
17
            .get_or_init(|| async {
603
                #[cfg(feature = "testlog")]
604
1
                let _ = env_logger::builder()
605
1
                    .is_test(true)
606
1
                    .filter_level(log::LevelFilter::Trace)
607
1
                    .try_init();
608
2
            })
609
17
            .await;
610
17
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
611
17
            .await;
612
17
    }
613

            
614
    #[local_db_sqlx_test]
615
    async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
616
        let user = USER.get().unwrap();
617
        user.commit()
618
            .await
619
            .expect("Failed to commit user to database");
620

            
621
        // First create a commodity
622
        let commodity_result = CreateCommodity::new()
623
            .symbol("TST".to_string())
624
            .name("Test Commodity".to_string())
625
            .user_id(user.id)
626
            .run()
627
            .await?;
628

            
629
        // Get the commodity ID
630
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
631
            uuid::Uuid::parse_str(&id)?
632
        } else {
633
            panic!("Expected commodity ID string result");
634
        };
635

            
636
        // Create two accounts
637
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
638
            CreateAccount::new()
639
                .name("Account 1".to_string())
640
                .user_id(user.id)
641
                .run()
642
                .await?
643
        {
644
            account
645
        } else {
646
            panic!("Expected account entity result");
647
        };
648

            
649
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
650
            CreateAccount::new()
651
                .name("Account 2".to_string())
652
                .user_id(user.id)
653
                .run()
654
                .await?
655
        {
656
            account
657
        } else {
658
            panic!("Expected account entity result");
659
        };
660

            
661
        let tx_id = Uuid::new_v4();
662

            
663
        // Create splits
664
        let split1 = Split::builder()
665
            .id(Uuid::new_v4())
666
            .tx_id(tx_id)
667
            .account_id(account1.id)
668
            .commodity_id(commodity_id)
669
            .value_num(100)
670
            .value_denom(1)
671
            .build()?;
672

            
673
        let split2 = Split::builder()
674
            .id(Uuid::new_v4())
675
            .tx_id(tx_id)
676
            .account_id(account2.id)
677
            .commodity_id(commodity_id)
678
            .value_num(-100)
679
            .value_denom(1)
680
            .build()?;
681

            
682
        // Create transaction with splits
683
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
684
        let now = Utc::now();
685

            
686
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) = CreateTransaction::new()
687
            .user_id(user.id)
688
            .splits(splits)
689
            .id(tx_id)
690
            .post_date(now)
691
            .enter_date(now)
692
            .run()
693
            .await?
694
        {
695
            assert!(!tx.id.is_nil());
696

            
697
            // Verify splits were created
698
            let mut conn = user.get_connection().await?;
699
            let splits = sqlx::query_file!("sql/count/splits/by_transaction.sql", tx.id)
700
                .fetch_one(&mut *conn)
701
                .await?;
702
            assert_eq!(splits.count, Some(2));
703
        } else {
704
            panic!("Expected transaction entity result");
705
        }
706
    }
707
    #[local_db_sqlx_test]
708
    async fn test_list_transactions_empty(pool: PgPool) -> anyhow::Result<()> {
709
        let user = USER.get().unwrap();
710
        user.commit()
711
            .await
712
            .expect("Failed to commit user to database");
713

            
714
        if let Some(CmdResult::TaggedEntities {
715
            entities,
716
            pagination: Some(pagination),
717
        }) = ListTransactions::new().user_id(user.id).run().await?
718
        {
719
            assert!(
720
                entities.is_empty(),
721
                "Expected no transactions in empty database"
722
            );
723
            assert_eq!(pagination.total_count, 0);
724
            assert_eq!(pagination.limit, 20);
725
            assert_eq!(pagination.offset, 0);
726
            assert!(!pagination.has_more);
727
        } else {
728
            panic!("Expected TaggedEntities result with pagination");
729
        }
730
    }
731

            
732
    #[local_db_sqlx_test]
733
    async fn test_list_transactions_with_data(pool: PgPool) -> anyhow::Result<()> {
734
        let user = USER.get().unwrap();
735
        user.commit()
736
            .await
737
            .expect("Failed to commit user to database");
738

            
739
        // First create a commodity
740
        let commodity_result = CreateCommodity::new()
741
            .symbol("TST".to_string())
742
            .name("Test Commodity".to_string())
743
            .user_id(user.id)
744
            .run()
745
            .await?;
746

            
747
        // Get the commodity ID
748
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
749
            uuid::Uuid::parse_str(&id)?
750
        } else {
751
            panic!("Expected commodity ID string result");
752
        };
753

            
754
        // Create two accounts
755
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
756
            CreateAccount::new()
757
                .name("Account 1".to_string())
758
                .user_id(user.id)
759
                .run()
760
                .await?
761
        {
762
            account
763
        } else {
764
            panic!("Expected account entity result");
765
        };
766

            
767
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
768
            CreateAccount::new()
769
                .name("Account 2".to_string())
770
                .user_id(user.id)
771
                .run()
772
                .await?
773
        {
774
            account
775
        } else {
776
            panic!("Expected account entity result");
777
        };
778

            
779
        // Create a transaction between the accounts
780
        let tx_id = Uuid::new_v4();
781
        let now = Utc::now();
782

            
783
        let split1 = Split {
784
            id: Uuid::new_v4(),
785
            tx_id,
786
            account_id: account1.id,
787
            commodity_id,
788
            value_num: -100,
789
            value_denom: 1,
790
            reconcile_state: None,
791
            reconcile_date: None,
792
            lot_id: None,
793
        };
794

            
795
        let split2 = Split {
796
            id: Uuid::new_v4(),
797
            tx_id,
798
            account_id: account2.id,
799
            commodity_id,
800
            value_num: 100,
801
            value_denom: 1,
802
            reconcile_state: None,
803
            reconcile_date: None,
804
            lot_id: None,
805
        };
806

            
807
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
808
        CreateTransaction::new()
809
            .user_id(user.id)
810
            .splits(splits)
811
            .id(tx_id)
812
            .post_date(now)
813
            .enter_date(now)
814
            .run()
815
            .await?;
816

            
817
        // List all transactions
818
        if let Some(CmdResult::TaggedEntities {
819
            entities,
820
            pagination: Some(pagination),
821
        }) = ListTransactions::new().user_id(user.id).run().await?
822
        {
823
            assert_eq!(entities.len(), 1, "Expected one transaction");
824
            assert_eq!(pagination.total_count, 1);
825

            
826
            let (entity, _tags) = &entities[0];
827
            if let FinanceEntity::Transaction(tx) = entity {
828
                assert_eq!(tx.id, tx_id);
829
            } else {
830
                panic!("Expected Transaction entity");
831
            }
832
        } else {
833
            panic!("Expected TaggedEntities result with pagination");
834
        }
835

            
836
        // List transactions filtered by account
837
        if let Some(CmdResult::TaggedEntities { entities, .. }) = ListTransactions::new()
838
            .user_id(user.id)
839
            .account(account1.id)
840
            .run()
841
            .await?
842
        {
843
            assert_eq!(entities.len(), 1, "Expected one transaction for account1");
844
        } else {
845
            panic!("Expected TaggedEntities result");
846
        }
847

            
848
        // List transactions for non-existent account
849
        if let Some(CmdResult::TaggedEntities { entities, .. }) = ListTransactions::new()
850
            .user_id(user.id)
851
            .account(Uuid::new_v4())
852
            .run()
853
            .await?
854
        {
855
            assert_eq!(
856
                entities.len(),
857
                0,
858
                "Expected no transactions for non-existent account"
859
            );
860
        } else {
861
            panic!("Expected TaggedEntities result");
862
        }
863
    }
864

            
865
    #[local_db_sqlx_test]
866
    async fn test_get_transaction(pool: PgPool) -> anyhow::Result<()> {
867
        let user = USER.get().unwrap();
868
        user.commit()
869
            .await
870
            .expect("Failed to commit user to database");
871

            
872
        // First create a commodity
873
        let commodity_result = CreateCommodity::new()
874
            .symbol("TST".to_string())
875
            .name("Test Commodity".to_string())
876
            .user_id(user.id)
877
            .run()
878
            .await?;
879

            
880
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
881
            uuid::Uuid::parse_str(&id)?
882
        } else {
883
            panic!("Expected commodity ID string result");
884
        };
885

            
886
        // Create two accounts
887
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
888
            CreateAccount::new()
889
                .name("Account 1".to_string())
890
                .user_id(user.id)
891
                .run()
892
                .await?
893
        {
894
            account
895
        } else {
896
            panic!("Expected account entity result");
897
        };
898

            
899
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
900
            CreateAccount::new()
901
                .name("Account 2".to_string())
902
                .user_id(user.id)
903
                .run()
904
                .await?
905
        {
906
            account
907
        } else {
908
            panic!("Expected account entity result");
909
        };
910

            
911
        // Create a transaction
912
        let tx_id = Uuid::new_v4();
913
        let split1 = Split {
914
            id: Uuid::new_v4(),
915
            tx_id,
916
            account_id: account1.id,
917
            commodity_id,
918
            value_num: -100,
919
            value_denom: 1,
920
            reconcile_state: None,
921
            reconcile_date: None,
922
            lot_id: None,
923
        };
924
        let split2 = Split {
925
            id: Uuid::new_v4(),
926
            tx_id,
927
            account_id: account2.id,
928
            commodity_id,
929
            value_num: 100,
930
            value_denom: 1,
931
            reconcile_state: None,
932
            reconcile_date: None,
933
            lot_id: None,
934
        };
935
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
936
        let now = Utc::now();
937

            
938
        CreateTransaction::new()
939
            .user_id(user.id)
940
            .splits(splits)
941
            .id(tx_id)
942
            .post_date(now)
943
            .enter_date(now)
944
            .note("Test transaction".to_string())
945
            .run()
946
            .await?;
947

            
948
        // Test GetTransaction
949
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetTransaction::new()
950
            .user_id(user.id)
951
            .transaction_id(tx_id)
952
            .run()
953
            .await?
954
        {
955
            assert_eq!(entities.len(), 1, "Expected one transaction");
956
            let (entity, _tags) = &entities[0];
957
            if let FinanceEntity::Transaction(tx) = entity {
958
                assert_eq!(tx.id, tx_id);
959
            } else {
960
                panic!("Expected Transaction entity");
961
            }
962
        } else {
963
            panic!("Expected TaggedEntities result");
964
        }
965

            
966
        // Test GetTransaction with non-existent ID
967
        let result = GetTransaction::new()
968
            .user_id(user.id)
969
            .transaction_id(Uuid::new_v4())
970
            .run()
971
            .await?;
972
        assert!(
973
            result.is_none(),
974
            "Expected None for non-existent transaction"
975
        );
976
    }
977

            
978
    #[local_db_sqlx_test]
979
    async fn test_update_transaction(pool: PgPool) -> anyhow::Result<()> {
980
        let user = USER.get().unwrap();
981
        user.commit()
982
            .await
983
            .expect("Failed to commit user to database");
984

            
985
        // First create a commodity
986
        let commodity_result = CreateCommodity::new()
987
            .symbol("TST".to_string())
988
            .name("Test Commodity".to_string())
989
            .user_id(user.id)
990
            .run()
991
            .await?;
992

            
993
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
994
            uuid::Uuid::parse_str(&id)?
995
        } else {
996
            panic!("Expected commodity ID string result");
997
        };
998

            
999
        // Create two accounts
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        // Create a transaction
        let tx_id = Uuid::new_v4();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .note("Original note".to_string())
            .run()
            .await?;
        // Test UpdateTransaction with only note change
        let new_note = "Updated note".to_string();
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
            UpdateTransaction::new()
                .user_id(user.id)
                .transaction_id(tx_id)
                .note(new_note.clone())
                .run()
                .await?
        {
            assert_eq!(updated_tx.id, tx_id);
        } else {
            panic!("Expected Transaction entity result");
        }
        // Test UpdateTransaction with new splits
        let new_split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -200,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let new_split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 200,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let new_splits = vec![
            FinanceEntity::Split(new_split1),
            FinanceEntity::Split(new_split2),
        ];
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
            UpdateTransaction::new()
                .user_id(user.id)
                .transaction_id(tx_id)
                .splits(new_splits)
                .run()
                .await?
        {
            assert_eq!(updated_tx.id, tx_id);
        } else {
            panic!("Expected Transaction entity result");
        }
        // Test UpdateTransaction atomicity: unbalanced splits should fail
        let unbalanced_split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100, // This doesn't balance with split2
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let unbalanced_split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 50, // Should be 100 to balance
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let unbalanced_splits = vec![
            FinanceEntity::Split(unbalanced_split1),
            FinanceEntity::Split(unbalanced_split2),
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .splits(unbalanced_splits)
            .run()
            .await;
        assert!(result.is_err(), "Expected error for unbalanced splits");
        // Verify original transaction is unchanged after failed update
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 1, "Expected one transaction");
            // Transaction should still exist and be unchanged
        } else {
            panic!("Expected transaction to still exist after failed update");
        }
        // Test UpdateTransaction with non-existent transaction
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(Uuid::new_v4())
            .note("Should fail".to_string())
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for non-existent transaction"
        );
    }
    #[local_db_sqlx_test]
    async fn test_update_transaction_atomicity(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // First create a commodity
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        // Create two accounts
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        // Create a transaction
        let tx_id = Uuid::new_v4();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .note("Original transaction".to_string())
            .run()
            .await?;
        // Test 1: Split transaction ID mismatch validation
        let wrong_tx_id = Uuid::new_v4();
        let invalid_split = Split {
            id: Uuid::new_v4(),
            tx_id: wrong_tx_id, // Wrong transaction ID
            account_id: account1.id,
            commodity_id,
            value_num: -50,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let valid_split = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 50,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let mismatched_splits = vec![
            FinanceEntity::Split(invalid_split),
            FinanceEntity::Split(valid_split),
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .splits(mismatched_splits)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for split transaction ID mismatch"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Split transaction ID mismatch"));
        } else {
            panic!("Expected CmdError::Args with transaction ID mismatch message");
        }
        // Verify original transaction is unchanged
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 1, "Expected one transaction");
        } else {
            panic!("Expected transaction to still exist after failed update");
        }
        // Test 2: Invalid entity type in splits
        let invalid_splits = vec![
            FinanceEntity::Account(Account {
                id: account1.id,
                parent: account1.parent,
            }), // Wrong entity type
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .splits(invalid_splits)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for invalid entity type in splits"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Invalid entity type in splits"));
        } else {
            panic!("Expected CmdError::Args with invalid entity type message");
        }
        // Test 3: Invalid entity type in prices
        let invalid_prices = vec![
            FinanceEntity::Account(Account {
                id: account1.id,
                parent: account1.parent,
            }), // Wrong entity type
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .prices(invalid_prices)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for invalid entity type in prices"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Invalid entity type in prices"));
        } else {
            panic!("Expected CmdError::Args with invalid entity type message");
        }
        // Test 4: Invalid entity type in tags
        let mut invalid_tags = HashMap::new();
        invalid_tags.insert(
            "test".to_string(),
            FinanceEntity::Account(Account {
                id: account1.id,
                parent: account1.parent,
            }),
        );
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .tags(invalid_tags)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for invalid entity type in tags"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Invalid entity type in tags"));
        } else {
            panic!("Expected CmdError::Args with invalid entity type message");
        }
        // Test 5: Database rollback verification - count splits before and after failed update
        let mut conn = user.get_connection().await?;
        let initial_split_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        // Try an update that will fail during split insertion (invalid account ID)
        let invalid_account_split = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: Uuid::new_v4(), // Non-existent account
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let balancing_split = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let failing_splits = vec![
            FinanceEntity::Split(invalid_account_split),
            FinanceEntity::Split(balancing_split),
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .splits(failing_splits)
            .run()
            .await;
        assert!(result.is_err(), "Expected error for non-existent account");
        // Verify splits count is unchanged (rollback occurred)
        let final_split_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(
            initial_split_count, final_split_count,
            "Split count should be unchanged after failed update due to rollback"
        );
        // Verify original transaction is still intact
        if let Some(CmdResult::TaggedEntities { entities, .. }) = GetTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 1, "Expected one transaction");
        } else {
            panic!("Expected transaction to still exist after failed database operation");
        }
    }
    #[local_db_sqlx_test]
    async fn test_update_transaction_prices_and_tags(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // Create a simple commodity for testing
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        // Create accounts
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        // Create tags for testing manually
        let tag1_id = Uuid::new_v4();
        let tag2_id = Uuid::new_v4();
        let mut conn = user.get_connection().await?;
        sqlx::query!(
            "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
            tag1_id,
            "category",
            "expense",
            Some("Expense category".to_string())
        )
        .execute(&mut *conn)
        .await?;
        sqlx::query!(
            "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
            tag2_id,
            "project",
            "finance_app",
            Some("Finance app project".to_string())
        )
        .execute(&mut *conn)
        .await?;
        // Create initial transaction with simple same-commodity splits
        let tx_id = Uuid::new_v4();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .note("Initial transaction".to_string())
            .run()
            .await?;
        // Test 1: Update transaction with standalone prices (not linked to splits)
        let price1 = Price {
            id: Uuid::new_v4(),
            commodity_id,
            currency_id: commodity_id, // Same commodity for simplicity
            commodity_split: None,     // Not linked to specific splits
            currency_split: None,
            date: now,
            value_num: 100,
            value_denom: 100,
        };
        let prices = vec![FinanceEntity::Price(price1)];
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
            UpdateTransaction::new()
                .user_id(user.id)
                .transaction_id(tx_id)
                .prices(prices)
                .run()
                .await?
        {
            assert_eq!(updated_tx.id, tx_id);
        } else {
            panic!("Expected Transaction entity result for price update");
        }
        // Verify prices were inserted
        let price_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1 AND currency_id = $2",
            commodity_id,
            commodity_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(price_count, 1, "Expected one price record");
        // Test 2: Update transaction with tags
        let mut tags = HashMap::new();
        tags.insert(
            "category".to_string(),
            FinanceEntity::Tag(Tag {
                id: tag1_id,
                tag_name: "category".to_string(),
                tag_value: "expense".to_string(),
                description: Some("Expense category".to_string()),
            }),
        );
        tags.insert(
            "project".to_string(),
            FinanceEntity::Tag(Tag {
                id: tag2_id,
                tag_name: "project".to_string(),
                tag_value: "finance_app".to_string(),
                description: Some("Finance app project".to_string()),
            }),
        );
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
            UpdateTransaction::new()
                .user_id(user.id)
                .transaction_id(tx_id)
                .tags(tags)
                .run()
                .await?
        {
            assert_eq!(updated_tx.id, tx_id);
        } else {
            panic!("Expected Transaction entity result for tag update");
        }
        // Verify tags were inserted
        let tag_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(tag_count, 2, "Expected two tag records");
        // Test 3: Combined update (new splits, new prices, and new tags)
        let new_split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -200,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let new_split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 200,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let new_splits = vec![
            FinanceEntity::Split(new_split1),
            FinanceEntity::Split(new_split2),
        ];
        let new_price = Price {
            id: Uuid::new_v4(),
            commodity_id,
            currency_id: commodity_id,
            commodity_split: None,
            currency_split: None,
            date: now,
            value_num: 110,
            value_denom: 100,
        };
        let new_prices = vec![FinanceEntity::Price(new_price)];
        let mut new_tags = HashMap::new();
        new_tags.insert(
            "category".to_string(),
            FinanceEntity::Tag(Tag {
                id: tag1_id,
                tag_name: "category".to_string(),
                tag_value: "income".to_string(), // Changed value
                description: Some("Expense category".to_string()),
            }),
        );
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
            UpdateTransaction::new()
                .user_id(user.id)
                .transaction_id(tx_id)
                .splits(new_splits)
                .prices(new_prices)
                .tags(new_tags)
                .run()
                .await?
        {
            assert_eq!(updated_tx.id, tx_id);
        } else {
            panic!("Expected Transaction entity result for combined update");
        }
        // Verify all updates were applied atomically
        let final_split_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(final_split_count, 2, "Expected two splits after update");
        let final_price_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1",
            commodity_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(final_price_count, 2, "Expected two prices after update");
        let final_tag_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(final_tag_count, 1, "Expected one tag after update");
        // Test 4: Tag validation failure with non-existent tag
        let mut invalid_tags = HashMap::new();
        invalid_tags.insert(
            "invalid".to_string(),
            FinanceEntity::Tag(Tag {
                id: Uuid::new_v4(), // Non-existent tag
                tag_name: "invalid".to_string(),
                tag_value: "value".to_string(),
                description: Some("Invalid tag".to_string()),
            }),
        );
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .tags(invalid_tags)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for invalid tag with non-existent tag ID"
        );
        // Verify original tags are unchanged after failed tag update
        let unchanged_tag_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(
            unchanged_tag_count, 1,
            "Tag count should be unchanged after failed update"
        );
    }
    #[local_db_sqlx_test]
    async fn test_delete_transaction_simple(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let tx_id = Uuid::new_v4();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .run()
            .await?;
        let result = DeleteTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?;
        assert!(result.is_some(), "Expected successful deletion");
        let mut conn = user.get_connection().await?;
        let tx_exists = sqlx::query!(
            "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(tx_exists, 0, "Transaction should be deleted");
        let splits_exist = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(splits_exist, 0, "Splits should be deleted");
    }
    #[local_db_sqlx_test]
    async fn test_delete_transaction_with_tags_and_prices(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let tx_id = Uuid::new_v4();
        let split1_id = Uuid::new_v4();
        let split2_id = Uuid::new_v4();
        let split1 = Split {
            id: split1_id,
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: split2_id,
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        let price = Price {
            id: Uuid::new_v4(),
            commodity_id,
            currency_id: commodity_id,
            commodity_split: Some(split1_id),
            currency_split: Some(split2_id),
            date: now,
            value_num: 100,
            value_denom: 100,
        };
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .prices(vec![FinanceEntity::Price(price)])
            .note("Test note".to_string())
            .run()
            .await?;
        let mut conn = user.get_connection().await?;
        let tag_count_before = sqlx::query!("SELECT COUNT(*) as count FROM tags")
            .fetch_one(&mut *conn)
            .await?
            .count
            .unwrap_or(0);
        assert!(tag_count_before > 0, "Should have tags before deletion");
        let price_count_before = sqlx::query!(
            "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
            split1_id,
            split2_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(
            price_count_before, 1,
            "Should have one price before deletion"
        );
        let result = DeleteTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?;
        assert!(result.is_some(), "Expected successful deletion");
        let tx_exists = sqlx::query!(
            "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(tx_exists, 0, "Transaction should be deleted");
        let splits_exist = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(splits_exist, 0, "Splits should be deleted");
        let tx_tags_exist = sqlx::query!(
            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(
            tx_tags_exist, 0,
            "Transaction tags associations should be deleted"
        );
        let prices_exist = sqlx::query!(
            "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
            split1_id,
            split2_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(prices_exist, 0, "Prices should be deleted");
    }
    #[local_db_sqlx_test]
    async fn test_delete_transaction_nonexistent(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let nonexistent_id = Uuid::new_v4();
        let result = DeleteTransaction::new()
            .user_id(user.id)
            .transaction_id(nonexistent_id)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for non-existent transaction"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Transaction not found"));
        } else {
            panic!("Expected CmdError::Args with 'Transaction not found' message");
        }
    }
    #[local_db_sqlx_test]
    async fn test_delete_transaction_orphaned_tags(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let tx_id = Uuid::new_v4();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .note("Orphaned tag test".to_string())
            .run()
            .await?;
        let mut conn = user.get_connection().await?;
        let tag_id = sqlx::query!(
            "SELECT tag_id FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .tag_id;
        DeleteTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?;
        let orphaned_tag_exists =
            sqlx::query!("SELECT COUNT(*) as count FROM tags WHERE id = $1", tag_id)
                .fetch_one(&mut *conn)
                .await?
                .count
                .unwrap_or(0);
        assert_eq!(orphaned_tag_exists, 0, "Orphaned tag should be deleted");
    }
    const GROCERIES_SCRIPT_WASM: &[u8] =
        include_bytes!("../../../web/static/wasm/groceries_markup.wasm");
    const TAG_SYNC_SCRIPT_WASM: &[u8] = include_bytes!("../../../web/static/wasm/tag_sync.wasm");
    #[local_db_sqlx_test]
    async fn test_create_transaction_with_all_scripts_completes(
        pool: PgPool,
    ) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let mut conn = user.get_connection().await?;
        let groceries_script_id = Uuid::new_v4();
        sqlx::query_file!(
            "sql/insert/scripts/script.sql",
            groceries_script_id,
            GROCERIES_SCRIPT_WASM
        )
        .execute(&mut *conn)
        .await?;
        let tag_sync_script_id = Uuid::new_v4();
        sqlx::query_file!(
            "sql/insert/scripts/script.sql",
            tag_sync_script_id,
            TAG_SYNC_SCRIPT_WASM
        )
        .execute(&mut *conn)
        .await?;
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let tx_id = Uuid::new_v4();
        let split1_id = Uuid::new_v4();
        let split2_id = Uuid::new_v4();
        let split1 = Split {
            id: split1_id,
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -5000,
            value_denom: 100,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: split2_id,
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 5000,
            value_denom: 100,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        let result = tokio::time::timeout(
            std::time::Duration::from_secs(10),
            CreateTransaction::new()
                .user_id(user.id)
                .splits(splits)
                .id(tx_id)
                .post_date(now)
                .enter_date(now)
                .note("groceries".to_string())
                .run(),
        )
        .await;
        assert!(
            result.is_ok(),
            "Transaction creation with scripts must complete within 10 seconds"
        );
        result.unwrap()?;
        let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
            .fetch_all(&mut *conn)
            .await?;
        let split1_has_category = split1_tags
            .iter()
1
            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
        assert!(
            split1_has_category,
            "Split 1 should have category=groceries tag from groceries script"
        );
        sqlx::query_file!("sql/delete/scripts/by_id.sql", groceries_script_id)
            .execute(&mut *conn)
            .await?;
        sqlx::query_file!("sql/delete/scripts/by_id.sql", tag_sync_script_id)
            .execute(&mut *conn)
            .await?;
    }
    #[local_db_sqlx_test]
    async fn test_create_transaction_with_script(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let mut conn = user.get_connection().await?;
        // Insert the groceries script into the database
        let script_id = Uuid::new_v4();
        sqlx::query_file!(
            "sql/insert/scripts/script.sql",
            script_id,
            GROCERIES_SCRIPT_WASM
        )
        .execute(&mut *conn)
        .await?;
        // Create commodity and accounts
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let tx_id = Uuid::new_v4();
        let split1_id = Uuid::new_v4();
        let split2_id = Uuid::new_v4();
        let split1 = Split {
            id: split1_id,
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -5000,
            value_denom: 100,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: split2_id,
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 5000,
            value_denom: 100,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        // Create transaction with note="groceries" - this should trigger the script
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .note("groceries".to_string())
            .run()
            .await?;
        // Verify that the script added "category=groceries" tags to both splits
        let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
            .fetch_all(&mut *conn)
            .await?;
        let split2_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split2_id)
            .fetch_all(&mut *conn)
            .await?;
        // Check split1 has the category tag
        let split1_has_category = split1_tags
            .iter()
1
            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
        assert!(
            split1_has_category,
            "Split 1 should have category=groceries tag from script. Tags: {:?}",
            split1_tags
                .iter()
                .map(|t| format!("{}={}", t.tag_name, t.tag_value))
                .collect::<Vec<_>>()
        );
        // Check split2 has the category tag
        let split2_has_category = split2_tags
            .iter()
1
            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
        assert!(
            split2_has_category,
            "Split 2 should have category=groceries tag from script. Tags: {:?}",
            split2_tags
                .iter()
                .map(|t| format!("{}={}", t.tag_name, t.tag_value))
                .collect::<Vec<_>>()
        );
        // Clean up the script
        sqlx::query_file!("sql/delete/scripts/by_id.sql", script_id)
            .execute(&mut *conn)
            .await?;
    }
    #[local_db_sqlx_test]
    async fn test_create_transaction_script_skips_non_matching(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let mut conn = user.get_connection().await?;
        // Insert the groceries script into the database
        let script_id = Uuid::new_v4();
        sqlx::query_file!(
            "sql/insert/scripts/script.sql",
            script_id,
            GROCERIES_SCRIPT_WASM
        )
        .execute(&mut *conn)
        .await?;
        // Create commodity and accounts
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let tx_id = Uuid::new_v4();
        let split1_id = Uuid::new_v4();
        let split2_id = Uuid::new_v4();
        let split1 = Split {
            id: split1_id,
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -5000,
            value_denom: 100,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: split2_id,
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 5000,
            value_denom: 100,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        // Create transaction with note="other" - this should NOT trigger the script
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .note("other".to_string())
            .run()
            .await?;
        // Verify that the script did NOT add any tags to splits
        let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
            .fetch_all(&mut *conn)
            .await?;
        let split2_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split2_id)
            .fetch_all(&mut *conn)
            .await?;
        // Neither split should have the category tag
        let split1_has_category = split1_tags
            .iter()
            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
        assert!(
            !split1_has_category,
            "Split 1 should NOT have category tag for non-groceries transaction"
        );
        let split2_has_category = split2_tags
            .iter()
            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
        assert!(
            !split2_has_category,
            "Split 2 should NOT have category tag for non-groceries transaction"
        );
        // Clean up the script
        sqlx::query_file!("sql/delete/scripts/by_id.sql", script_id)
            .execute(&mut *conn)
            .await?;
    }
80
    async fn create_test_transaction(
80
        user: &User,
80
        account1_id: Uuid,
80
        account2_id: Uuid,
80
        commodity_id: Uuid,
80
        post_date: DateTime<Utc>,
80
        amount: i64,
80
    ) -> anyhow::Result<Uuid> {
80
        let tx_id = Uuid::new_v4();
80
        let split1 = Split {
80
            id: Uuid::new_v4(),
80
            tx_id,
80
            account_id: account1_id,
80
            commodity_id,
80
            value_num: -amount,
80
            value_denom: 1,
80
            reconcile_state: None,
80
            reconcile_date: None,
80
            lot_id: None,
80
        };
80
        let split2 = Split {
80
            id: Uuid::new_v4(),
80
            tx_id,
80
            account_id: account2_id,
80
            commodity_id,
80
            value_num: amount,
80
            value_denom: 1,
80
            reconcile_state: None,
80
            reconcile_date: None,
80
            lot_id: None,
80
        };
80
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
80
        CreateTransaction::new()
80
            .user_id(user.id)
80
            .splits(splits)
80
            .id(tx_id)
80
            .post_date(post_date)
80
            .enter_date(Utc::now())
80
            .run()
80
            .await?;
80
        Ok(tx_id)
80
    }
    #[local_db_sqlx_test]
    async fn test_pagination_limit(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let base_time = Utc::now();
        for i in 0..25 {
            let post_date = base_time - Duration::days(i);
            create_test_transaction(
                user,
                account1.id,
                account2.id,
                commodity_id,
                post_date,
                100 + i,
            )
            .await?;
        }
        // Test limit=10 returns exactly 10 transactions
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .limit(10)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 10, "Expected exactly 10 transactions");
            assert_eq!(pagination.total_count, 25);
            assert_eq!(pagination.limit, 10);
            assert_eq!(pagination.offset, 0);
            assert!(pagination.has_more);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
        // Test limit=5 returns exactly 5 transactions
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .limit(5)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 5, "Expected exactly 5 transactions");
            assert_eq!(pagination.total_count, 25);
            assert!(pagination.has_more);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
        // Test limit=100 returns all 25 transactions (limit > total)
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .limit(100)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 25, "Expected all 25 transactions");
            assert_eq!(pagination.total_count, 25);
            assert!(!pagination.has_more);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
    }
    #[local_db_sqlx_test]
    async fn test_pagination_offset(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let base_time = Utc::now();
        for i in 0..25 {
            let post_date = base_time - Duration::days(i);
            create_test_transaction(
                user,
                account1.id,
                account2.id,
                commodity_id,
                post_date,
                100 + i,
            )
            .await?;
        }
        // Test offset=10, limit=10 returns second page
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .limit(10)
            .offset(10)
            .run()
            .await?
        {
            assert_eq!(
                entities.len(),
                10,
                "Expected 10 transactions on second page"
            );
            assert_eq!(pagination.total_count, 25);
            assert_eq!(pagination.offset, 10);
            assert!(pagination.has_more);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
        // Test offset=20, limit=10 returns last page (only 5 remaining)
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .limit(10)
            .offset(20)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 5, "Expected 5 transactions on last page");
            assert_eq!(pagination.total_count, 25);
            assert!(!pagination.has_more);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
        // Test offset beyond total returns empty
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .limit(10)
            .offset(100)
            .run()
            .await?
        {
            assert!(entities.is_empty(), "Expected no transactions beyond total");
            assert_eq!(pagination.total_count, 25);
            assert!(!pagination.has_more);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
    }
    #[local_db_sqlx_test]
    async fn test_pagination_date_filter(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let commodity_result = CreateCommodity::new()
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let base_time = Utc::now();
        for i in 0..30 {
            let post_date = base_time - Duration::days(i);
            create_test_transaction(
                user,
                account1.id,
                account2.id,
                commodity_id,
                post_date,
                100 + i,
            )
            .await?;
        }
        // Test date_from filter (last 10 days)
        let date_from = base_time - Duration::days(9);
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .date_from(date_from)
            .run()
            .await?
        {
            assert_eq!(
                entities.len(),
                10,
                "Expected 10 transactions from last 10 days"
            );
            assert_eq!(pagination.total_count, 10);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
        // Test date_to filter (older than 20 days)
        let date_to = base_time - Duration::days(20);
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .date_to(date_to)
            .run()
            .await?
        {
            assert_eq!(
                entities.len(),
                10,
                "Expected 10 transactions older than 20 days"
            );
            assert_eq!(pagination.total_count, 10);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
        // Test date range filter (days 10-19)
        let date_from = base_time - Duration::days(19);
        let date_to = base_time - Duration::days(10);
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .date_from(date_from)
            .date_to(date_to)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 10, "Expected 10 transactions in date range");
            assert_eq!(pagination.total_count, 10);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
        // Test date range with pagination
        let date_from = base_time - Duration::days(29);
        if let Some(CmdResult::TaggedEntities {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new()
            .user_id(user.id)
            .date_from(date_from)
            .limit(5)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 5, "Expected 5 transactions with limit");
            assert_eq!(pagination.total_count, 30);
            assert!(pagination.has_more);
        } else {
            panic!("Expected TaggedEntities result with pagination");
        }
    }
}