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
    } => {
38

            
39

            
40
        let user = User { id: user_id };
41

            
42
        let mut conn = user.get_connection().await.map_err(|err| {
43
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
44
            ConfigError::DB
45
        })?;
46

            
47
        let tx = Transaction {
48
            id,
49
            post_date,
50
            enter_date,
51
        };
52

            
53
        cfg_if! {
54
            if #[cfg(feature = "scripting")] {
55
                let (transaction, splits, prices, transaction_tags, split_tags) = {
56
                    let scripts: Vec<Vec<u8>> = sqlx::query_file!("sql/select/scripts/enabled.sql")
57
                        .fetch_all(&mut *conn)
58
                        .await?
59
                        .into_iter()
60
                        .map(|row| row.bytecode)
61
                        .collect();
62

            
63
                    let state = TransactionState::new(tx)
64
                        .with(splits)
65
                        .with(prices.unwrap_or_default())
66
                        .with_note(note);
67

            
68
                    let state = if scripts.is_empty() {
69
                        state
70
                    } else {
71
                        let executor = ScriptExecutor::new();
72
                        state.run_scripts(&executor, &scripts).map_err(|e| {
73
                            log::error!("{}", t!("Script execution failed: %{err}", err = e : {:?}));
74
                            CmdError::Script(format!("{e:?}"))
75
                        })?
76
                    };
77

            
78
                    (state.transaction, state.splits, state.prices, state.transaction_tags, state.split_tags)
79
                };
80
            } else {
81
                let (transaction, splits, prices, transaction_tags, split_tags) = {
82
                    let splits: Vec<Split> = splits
83
                        .into_iter()
84
                        .filter_map(|e| match e {
85
                            FinanceEntity::Split(s) => Some(s),
86
                            _ => None,
87
                        })
88
                        .collect();
89

            
90
                    let prices: Vec<Price> = prices
91
                        .map(|ps| {
92
                            ps.into_iter()
93
                                .filter_map(|e| match e {
94
                                    FinanceEntity::Price(p) => Some(p),
95
                                    _ => None,
96
                                })
97
                                .collect()
98
                        })
99
                        .unwrap_or_default();
100

            
101
                    let transaction_tags: Vec<Tag> = note
102
                        .map(|n| {
103
                            vec![Tag {
104
                                id: Uuid::new_v4(),
105
                                tag_name: "note".to_string(),
106
                                tag_value: n,
107
                                description: None,
108
                            }]
109
                        })
110
                        .unwrap_or_default();
111

            
112
                    let split_tags: Vec<(Uuid, Tag)> = vec![];
113

            
114
                    (tx, splits, prices, transaction_tags, split_tags)
115
                };
116
            }
117
        }
118

            
119
        // Common ticket operations
120
        let mut ticket = transaction.enter(&mut *conn).await?;
121

            
122
        let split_refs: Vec<_> = splits.iter().collect();
123
        ticket.add_splits(&split_refs).await?;
124

            
125
        if !prices.is_empty() {
126
            let price_refs: Vec<_> = prices.iter().collect();
127
            ticket.add_conversions(&price_refs).await?;
128
        }
129

            
130
        if !transaction_tags.is_empty() {
131
            let tag_refs: Vec<_> = transaction_tags.iter().collect();
132
            ticket.add_tags(&tag_refs).await?;
133
        }
134

            
135
        if !split_tags.is_empty() {
136
            ticket.add_split_tags(&split_tags).await?;
137
        }
138

            
139
        ticket.commit().await?;
140

            
141
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(transaction))))
142
    }
143
757
}
144

            
145
command! {
146
    ListTransactions {
147
        #[required]
148
        user_id: Uuid,
149
        #[optional]
150
        account: Uuid,
151
        #[optional]
152
        limit: i64,
153
        #[optional]
154
        offset: i64,
155
        #[optional]
156
        date_from: DateTime<Utc>,
157
        #[optional]
158
        date_to: DateTime<Utc>,
159
    } => {
160
        let user = User { id: user_id };
161
11
        let mut conn = user.get_connection().await.map_err(|err| {
162
11
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
163
11
            ConfigError::DB
164
11
        })?;
165

            
166
        let account_uuid = account.as_ref();
167
        let effective_limit = limit.unwrap_or(20);
168
        let effective_offset = offset.unwrap_or(0);
169
        let date_from_ref = date_from.as_ref();
170
        let date_to_ref = date_to.as_ref();
171

            
172
        let count_result = sqlx::query_file!(
173
            "sql/count/transactions/filtered.sql",
174
            account_uuid,
175
            date_from_ref,
176
            date_to_ref
177
        )
178
        .fetch_one(&mut *conn)
179
        .await?;
180

            
181
        let total_count = count_result.count.unwrap_or(0);
182

            
183
        let transactions = sqlx::query_file!(
184
            "sql/select/transactions/paginated.sql",
185
            account_uuid,
186
            date_from_ref,
187
            date_to_ref,
188
            effective_limit,
189
            effective_offset
190
        )
191
        .fetch_all(&mut *conn)
192
        .await?;
193

            
194
        let mut tagged_transactions = Vec::new();
195
        for tx_row in transactions {
196
            let transaction = Transaction {
197
                id: tx_row.id,
198
                post_date: tx_row.post_date,
199
                enter_date: tx_row.enter_date,
200
            };
201

            
202
            let tags: HashMap<String, FinanceEntity> =
203
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
204
                    .fetch_all(&mut *conn)
205
                    .await?
206
                    .into_iter()
207
                    .map(|row| {
208
                        (
209
                            row.tag_name.clone(),
210
                            FinanceEntity::Tag(Tag {
211
                                id: row.id,
212
                                tag_name: row.tag_name,
213
                                tag_value: row.tag_value,
214
                                description: row.description,
215
                            }),
216
                        )
217
                    })
218
                    .collect();
219

            
220
            tagged_transactions.push((FinanceEntity::Transaction(transaction), tags));
221
        }
222

            
223
        let pagination = PaginationInfo {
224
            total_count,
225
            limit: effective_limit,
226
            offset: effective_offset,
227
            has_more: effective_offset + (tagged_transactions.len() as i64) < total_count,
228
        };
229

            
230
        Ok(Some(CmdResult::TaggedEntities {
231
            entities: tagged_transactions,
232
            pagination: Some(pagination),
233
        }))
234
    }
235
340
}
236

            
237
command! {
238
    GetTransaction {
239
        #[required]
240
        user_id: Uuid,
241
        #[required]
242
        transaction_id: Uuid,
243
    } => {
244
        let user = User { id: user_id };
245
        let mut conn = user.get_connection().await.map_err(|err| {
246
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
247
            ConfigError::DB
248
        })?;
249

            
250
        let tx_row = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
251
            .fetch_optional(&mut *conn)
252
            .await?;
253

            
254
        if let Some(tx_row) = tx_row {
255
            let transaction = Transaction {
256
                id: tx_row.id,
257
                post_date: tx_row.post_date,
258
                enter_date: tx_row.enter_date,
259
            };
260

            
261
            let tags: HashMap<String, FinanceEntity> =
262
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
263
                    .fetch_all(&mut *conn)
264
                    .await?
265
                    .into_iter()
266
4
                    .map(|row| {
267
4
                        (
268
4
                            row.tag_name.clone(),
269
4
                            FinanceEntity::Tag(Tag {
270
4
                                id: row.id,
271
4
                                tag_name: row.tag_name,
272
4
                                tag_value: row.tag_value,
273
4
                                description: row.description,
274
4
                            }),
275
4
                        )
276
4
                    })
277
                    .collect();
278

            
279
            Ok(Some(CmdResult::TaggedEntities {
280
                entities: vec![(FinanceEntity::Transaction(transaction), tags)],
281
                pagination: None,
282
            }))
283
        } else {
284
            Ok(None)
285
        }
286
    }
287
20
}
288

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

            
314
        let mut tx = conn.begin().await?;
315

            
316
        let existing = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
317
            .fetch_optional(&mut *tx)
318
            .await?
319
1
            .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
320

            
321
        let final_post_date = post_date.unwrap_or(existing.post_date);
322
        let final_enter_date = enter_date.unwrap_or(existing.enter_date);
323

            
324
        // Validate new splits if provided (before making any changes)
325
        if let Some(ref new_splits) = splits {
326
            let mut commodity_sums: std::collections::HashMap<Uuid, num_rational::Rational64> =
327
                std::collections::HashMap::new();
328

            
329
            for entity in new_splits {
330
                if let FinanceEntity::Split(split) = entity {
331
                    // Validate split belongs to this transaction
332
                    if split.tx_id != transaction_id {
333
                        return Err(CmdError::Args("Split transaction ID mismatch".to_string()));
334
                    }
335

            
336
                    let split_value =
337
                        num_rational::Rational64::new(split.value_num, split.value_denom);
338
                    *commodity_sums.entry(split.commodity_id).or_insert(
339
                        num_rational::Rational64::new(0, 1),
340
                    ) += split_value;
341
                } else {
342
                    return Err(CmdError::Args("Invalid entity type in splits".to_string()));
343
                }
344
            }
345

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

            
358
        // Update transaction metadata first
359
        sqlx::query_file!(
360
            "sql/update/transactions/update.sql",
361
            transaction_id,
362
            final_post_date,
363
            final_enter_date
364
        )
365
        .execute(&mut *tx)
366
        .await?;
367

            
368
        // Process splits update atomically: delete then insert
369
        if let Some(new_splits) = splits {
370
            // Delete existing split_tags, splits and their associated prices
371
            sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
372
                .execute(&mut *tx)
373
                .await?;
374

            
375
            sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
376
                .execute(&mut *tx)
377
                .await?;
378

            
379
            sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
380
                .execute(&mut *tx)
381
                .await?;
382

            
383
            // Insert new splits
384
            for entity in new_splits {
385
                if let FinanceEntity::Split(split) = entity {
386
                    sqlx::query_file!(
387
                        "sql/insert/splits/split.sql",
388
                        split.id,
389
                        split.tx_id,
390
                        split.account_id,
391
                        split.commodity_id,
392
                        split.reconcile_state,
393
                        split.reconcile_date,
394
                        split.value_num,
395
                        split.value_denom,
396
                        split.lot_id
397
                    )
398
                    .execute(&mut *tx)
399
                    .await?;
400
                }
401
            }
402
        }
403

            
404
        // Validate and process prices if provided
405
        if let Some(ref new_prices) = prices {
406
            for entity in new_prices {
407
                if let FinanceEntity::Price(_) = entity {
408
                    // Price validation could be added here
409
                } else {
410
                    return Err(CmdError::Args("Invalid entity type in prices".to_string()));
411
                }
412
            }
413
        }
414

            
415
        // Validate tags if provided
416
        if let Some(ref new_tags) = tags {
417
            for entity in new_tags.values() {
418
                if let FinanceEntity::Tag(_) = entity {
419
                    // Tag validation could be added here
420
                } else {
421
                    return Err(CmdError::Args("Invalid entity type in tags".to_string()));
422
                }
423
            }
424
        }
425

            
426
        // Process prices update atomically
427
        if let Some(new_prices) = prices {
428
            for entity in new_prices {
429
                if let FinanceEntity::Price(price) = entity {
430
                    sqlx::query_file!(
431
                        "sql/insert/prices/price.sql",
432
                        price.id,
433
                        price.commodity_id,
434
                        price.currency_id,
435
                        price.commodity_split,
436
                        price.currency_split,
437
                        price.date,
438
                        price.value_num,
439
                        price.value_denom
440
                    )
441
                    .execute(&mut *tx)
442
                    .await?;
443
                }
444
            }
445
        }
446

            
447
        // Process tags update atomically: delete then insert
448
        if let Some(new_tags) = tags {
449
            sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
450
                .execute(&mut *tx)
451
                .await?;
452

            
453
            for (_, entity) in new_tags {
454
                if let FinanceEntity::Tag(tag) = entity {
455
                    sqlx::query_file!(
456
                        "sql/insert/transaction_tags/transaction_tag.sql",
457
                        transaction_id,
458
                        tag.id
459
                    )
460
                    .execute(&mut *tx)
461
                    .await?;
462
                }
463
            }
464
        }
465

            
466
        // Handle note field by creating/updating note tag
467
        if let Some(note_value) = note {
468
            // First delete any existing note tag for this transaction
469
            sqlx::query!("DELETE FROM transaction_tags WHERE tx_id = $1 AND tag_id IN (SELECT id FROM tags WHERE tag_name = 'note')", transaction_id)
470
                .execute(&mut *tx)
471
                .await?;
472

            
473
            if !note_value.trim().is_empty() {
474
                // Create a new note tag
475
                let note_tag = Tag {
476
                    id: Uuid::new_v4(),
477
                    tag_name: "note".to_string(),
478
                    tag_value: note_value,
479
                    description: None,
480
                };
481

            
482
                // Insert the tag into the tags table
483
                sqlx::query_file!(
484
                    "sql/insert/tags/tag.sql",
485
                    note_tag.id,
486
                    note_tag.tag_name,
487
                    note_tag.tag_value,
488
                    note_tag.description
489
                )
490
                .execute(&mut *tx)
491
                .await?;
492

            
493
                // Link the tag to the transaction
494
                sqlx::query_file!(
495
                    "sql/insert/transaction_tags/transaction_tag.sql",
496
                    transaction_id,
497
                    note_tag.id
498
                )
499
                .execute(&mut *tx)
500
                .await?;
501
            }
502
        }
503

            
504
        tx.commit().await?;
505

            
506
        let updated_transaction = Transaction {
507
            id: transaction_id,
508
            post_date: final_post_date,
509
            enter_date: final_enter_date,
510
        };
511

            
512
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(updated_transaction))))
513
    }
514
67
}
515

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

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

            
534
        let mut tx = conn.begin().await?;
535

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

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

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

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

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

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

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

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

            
580
        tx.commit().await?;
581

            
582
        Ok(Some(CmdResult::String("Transaction deleted successfully".to_string())))
583
    }
584
16
}
585

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

            
599
    /// Context for keeping environment intact
600
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
601
    static USER: OnceCell<User> = OnceCell::const_new();
602

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

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

            
624
        // First create a commodity
625
        let commodity_result = CreateCommodity::new()
626
            .fraction(1.into())
627
            .symbol("TST".to_string())
628
            .name("Test Commodity".to_string())
629
            .user_id(user.id)
630
            .run()
631
            .await?;
632

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

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

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

            
665
        let tx_id = Uuid::new_v4();
666

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

            
677
        let split2 = Split::builder()
678
            .id(Uuid::new_v4())
679
            .tx_id(tx_id)
680
            .account_id(account2.id)
681
            .commodity_id(commodity_id)
682
            .value_num(-100)
683
            .value_denom(1)
684
            .build()?;
685

            
686
        // Create transaction with splits
687
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
688
        let now = Utc::now();
689

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

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

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

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

            
743
        // First create a commodity
744
        let commodity_result = CreateCommodity::new()
745
            .fraction(1.into())
746
            .symbol("TST".to_string())
747
            .name("Test Commodity".to_string())
748
            .user_id(user.id)
749
            .run()
750
            .await?;
751

            
752
        // Get the commodity ID
753
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
754
            uuid::Uuid::parse_str(&id)?
755
        } else {
756
            panic!("Expected commodity ID string result");
757
        };
758

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

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

            
784
        // Create a transaction between the accounts
785
        let tx_id = Uuid::new_v4();
786
        let now = Utc::now();
787

            
788
        let split1 = Split {
789
            id: Uuid::new_v4(),
790
            tx_id,
791
            account_id: account1.id,
792
            commodity_id,
793
            value_num: -100,
794
            value_denom: 1,
795
            reconcile_state: None,
796
            reconcile_date: None,
797
            lot_id: None,
798
        };
799

            
800
        let split2 = Split {
801
            id: Uuid::new_v4(),
802
            tx_id,
803
            account_id: account2.id,
804
            commodity_id,
805
            value_num: 100,
806
            value_denom: 1,
807
            reconcile_state: None,
808
            reconcile_date: None,
809
            lot_id: None,
810
        };
811

            
812
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
813
        CreateTransaction::new()
814
            .user_id(user.id)
815
            .splits(splits)
816
            .id(tx_id)
817
            .post_date(now)
818
            .enter_date(now)
819
            .run()
820
            .await?;
821

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

            
831
            let (entity, _tags) = &entities[0];
832
            if let FinanceEntity::Transaction(tx) = entity {
833
                assert_eq!(tx.id, tx_id);
834
            } else {
835
                panic!("Expected Transaction entity");
836
            }
837
        } else {
838
            panic!("Expected TaggedEntities result with pagination");
839
        }
840

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

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

            
870
    #[local_db_sqlx_test]
871
    async fn test_get_transaction(pool: PgPool) -> anyhow::Result<()> {
872
        let user = USER.get().unwrap();
873
        user.commit()
874
            .await
875
            .expect("Failed to commit user to database");
876

            
877
        // First create a commodity
878
        let commodity_result = CreateCommodity::new()
879
            .fraction(1.into())
880
            .symbol("TST".to_string())
881
            .name("Test Commodity".to_string())
882
            .user_id(user.id)
883
            .run()
884
            .await?;
885

            
886
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
887
            uuid::Uuid::parse_str(&id)?
888
        } else {
889
            panic!("Expected commodity ID string result");
890
        };
891

            
892
        // Create two accounts
893
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
894
            CreateAccount::new()
895
                .name("Account 1".to_string())
896
                .user_id(user.id)
897
                .run()
898
                .await?
899
        {
900
            account
901
        } else {
902
            panic!("Expected account entity result");
903
        };
904

            
905
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
906
            CreateAccount::new()
907
                .name("Account 2".to_string())
908
                .user_id(user.id)
909
                .run()
910
                .await?
911
        {
912
            account
913
        } else {
914
            panic!("Expected account entity result");
915
        };
916

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

            
944
        CreateTransaction::new()
945
            .user_id(user.id)
946
            .splits(splits)
947
            .id(tx_id)
948
            .post_date(now)
949
            .enter_date(now)
950
            .note("Test transaction".to_string())
951
            .run()
952
            .await?;
953

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

            
972
        // Test GetTransaction with non-existent ID
973
        let result = GetTransaction::new()
974
            .user_id(user.id)
975
            .transaction_id(Uuid::new_v4())
976
            .run()
977
            .await?;
978
        assert!(
979
            result.is_none(),
980
            "Expected None for non-existent transaction"
981
        );
982
    }
983

            
984
    #[local_db_sqlx_test]
985
    async fn test_update_transaction(pool: PgPool) -> anyhow::Result<()> {
986
        let user = USER.get().unwrap();
987
        user.commit()
988
            .await
989
            .expect("Failed to commit user to database");
990

            
991
        // First create a commodity
992
        let commodity_result = CreateCommodity::new()
993
            .fraction(1.into())
994
            .symbol("TST".to_string())
995
            .name("Test Commodity".to_string())
996
            .user_id(user.id)
997
            .run()
998
            .await?;
999

            
        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 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()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .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");
    #[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()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .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()
            .fraction(1.into())
            .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");
        }
    }
}