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

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

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

            
21
command! {
22
    CreateTransaction {
23
        #[required]
24
        user_id: Uuid,
25
        #[required]
26
        splits: Vec<FinanceEntity>,
27
        #[required]
28
        id: Uuid,
29
        #[required]
30
        post_date: DateTime<Utc>,
31
        #[required]
32
        enter_date: DateTime<Utc>,
33
        #[optional]
34
        prices: Vec<FinanceEntity>,
35
        #[optional]
36
        note: String,
37
        #[optional]
38
        split_tags: Vec<(Uuid, Tag)>,
39
    } => {
40

            
41

            
42
        let user = User { id: user_id };
43

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

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

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

            
65
                    let state = TransactionState::new(tx)
66
                        .with(splits)
67
                        .with(prices.unwrap_or_default())
68
                        .with_note(note)
69
                        .with_split_tags(split_tags.unwrap_or_default());
70

            
71
                    let state = if scripts.is_empty() {
72
                        state
73
                    } else {
74
3
                        let report = tokio::task::spawn_blocking(move || {
75
3
                            let executor = ScriptExecutor::new();
76
3
                            state.run_scripts(&executor, &scripts)
77
3
                        })
78
                        .await
79
                        .map_err(|e| CmdError::Script(format!("{e:?}")))?
80
                        .map_err(|e| {
81
                            log::error!("{}", t!("Script execution failed: %{err}", err = e : {:?}));
82
                            CmdError::Script(format!("{e:?}"))
83
                        })?;
84
                        for failure in &report.failures {
85
                            log::error!(
86
                                "{}",
87
                                t!(
88
                                    "Script %{id} failed: %{code}: %{message}",
89
                                    id = failure.script_id,
90
                                    code = failure.code,
91
                                    message = failure.message
92
                                )
93
                            );
94
                        }
95
                        report.state
96
                    };
97

            
98
                    (state.transaction, state.splits, state.prices, state.transaction_tags, state.split_tags)
99
                };
100
            } else {
101
                let (transaction, splits, prices, transaction_tags, split_tags) = {
102
                    let splits: Vec<Split> = splits
103
                        .into_iter()
104
                        .filter_map(|e| match e {
105
                            FinanceEntity::Split(s) => Some(s),
106
                            _ => None,
107
                        })
108
                        .collect();
109

            
110
                    let prices: Vec<Price> = prices
111
                        .map(|ps| {
112
                            ps.into_iter()
113
                                .filter_map(|e| match e {
114
                                    FinanceEntity::Price(p) => Some(p),
115
                                    _ => None,
116
                                })
117
                                .collect()
118
                        })
119
                        .unwrap_or_default();
120

            
121
                    let transaction_tags: Vec<Tag> = note
122
                        .map(|n| {
123
                            vec![Tag {
124
                                id: Uuid::new_v4(),
125
                                tag_name: "note".to_string(),
126
                                tag_value: n,
127
                                description: None,
128
                            }]
129
                        })
130
                        .unwrap_or_default();
131

            
132
                    let split_tags: Vec<(Uuid, Tag)> = split_tags.unwrap_or_default();
133

            
134
                    (tx, splits, prices, transaction_tags, split_tags)
135
                };
136
            }
137
        }
138

            
139
        // Common ticket operations
140
        let mut ticket = transaction.enter(&mut *conn).await?;
141

            
142
        let split_refs: Vec<_> = splits.iter().collect();
143
        ticket.add_splits(&split_refs).await?;
144

            
145
        if !prices.is_empty() {
146
            let price_refs: Vec<_> = prices.iter().collect();
147
            ticket.add_conversions(&price_refs).await?;
148
        }
149

            
150
        if !transaction_tags.is_empty() {
151
            let tag_refs: Vec<_> = transaction_tags.iter().collect();
152
            ticket.add_tags(&tag_refs).await?;
153
        }
154

            
155
        if !split_tags.is_empty() {
156
            ticket.add_split_tags(&split_tags).await?;
157
        }
158

            
159
        ticket.commit().await?;
160

            
161
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(transaction))))
162
    }
163
4382
}
164

            
165
command! {
166
    ListTransactions {
167
        #[required]
168
        user_id: Uuid,
169
        #[optional]
170
        account: Uuid,
171
        #[optional]
172
        limit: i64,
173
        #[optional]
174
        offset: i64,
175
        #[optional]
176
        date_from: DateTime<Utc>,
177
        #[optional]
178
        date_to: DateTime<Utc>,
179
    } => {
180
        let user = User { id: user_id };
181
110
        let mut conn = user.get_connection().await.map_err(|err| {
182
110
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
183
110
            ConfigError::DB
184
110
        })?;
185

            
186
        let account_uuid = account.as_ref();
187
        let effective_limit = limit.unwrap_or(20);
188
        let effective_offset = offset.unwrap_or(0);
189
        let date_from_ref = date_from.as_ref();
190
        let date_to_ref = date_to.as_ref();
191

            
192
        let count_result = sqlx::query_file!(
193
            "sql/count/transactions/filtered.sql",
194
            account_uuid,
195
            date_from_ref,
196
            date_to_ref
197
        )
198
        .fetch_one(&mut *conn)
199
        .await?;
200

            
201
        let total_count = count_result.count.unwrap_or(0);
202

            
203
        let transactions = sqlx::query_file!(
204
            "sql/select/transactions/paginated.sql",
205
            account_uuid,
206
            date_from_ref,
207
            date_to_ref,
208
            effective_limit,
209
            effective_offset
210
        )
211
        .fetch_all(&mut *conn)
212
        .await?;
213

            
214
        let mut tagged_transactions = Vec::new();
215
        for tx_row in transactions {
216
            let transaction = Transaction {
217
                id: tx_row.id,
218
                post_date: tx_row.post_date,
219
                enter_date: tx_row.enter_date,
220
            };
221

            
222
            let tags: HashMap<String, FinanceEntity> =
223
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
224
                    .fetch_all(&mut *conn)
225
                    .await?
226
                    .into_iter()
227
108
                    .map(|row| {
228
108
                        (
229
108
                            row.tag_name.clone(),
230
108
                            FinanceEntity::Tag(Tag {
231
108
                                id: row.id,
232
108
                                tag_name: row.tag_name,
233
108
                                tag_value: row.tag_value,
234
108
                                description: row.description,
235
108
                            }),
236
108
                        )
237
108
                    })
238
                    .collect();
239

            
240
            tagged_transactions.push((FinanceEntity::Transaction(transaction), tags));
241
        }
242

            
243
        let pagination = PaginationInfo {
244
            total_count,
245
            limit: effective_limit,
246
            offset: effective_offset,
247
            has_more: effective_offset + (tagged_transactions.len() as i64) < total_count,
248
        };
249

            
250
        Ok(Some(CmdResult::TaggedEntities {
251
            entities: tagged_transactions,
252
            pagination: Some(pagination),
253
        }))
254
    }
255
1600
}
256

            
257
command! {
258
    GetTransaction {
259
        #[required]
260
        user_id: Uuid,
261
        #[required]
262
        transaction_id: Uuid,
263
    } => {
264
        let user = User { id: user_id };
265
        let mut conn = user.get_connection().await.map_err(|err| {
266
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
267
            ConfigError::DB
268
        })?;
269

            
270
        let tx_row = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
271
            .fetch_optional(&mut *conn)
272
            .await?;
273

            
274
        if let Some(tx_row) = tx_row {
275
            let transaction = Transaction {
276
                id: tx_row.id,
277
                post_date: tx_row.post_date,
278
                enter_date: tx_row.enter_date,
279
            };
280

            
281
            let tags: HashMap<String, FinanceEntity> =
282
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
283
                    .fetch_all(&mut *conn)
284
                    .await?
285
                    .into_iter()
286
31
                    .map(|row| {
287
31
                        (
288
31
                            row.tag_name.clone(),
289
31
                            FinanceEntity::Tag(Tag {
290
31
                                id: row.id,
291
31
                                tag_name: row.tag_name,
292
31
                                tag_value: row.tag_value,
293
31
                                description: row.description,
294
31
                            }),
295
31
                        )
296
31
                    })
297
                    .collect();
298

            
299
            Ok(Some(CmdResult::TaggedEntities {
300
                entities: vec![(FinanceEntity::Transaction(transaction), tags)],
301
                pagination: None,
302
            }))
303
        } else {
304
            Ok(None)
305
        }
306
    }
307
236
}
308

            
309
command! {
310
    UpdateTransaction {
311
        #[required]
312
        user_id: Uuid,
313
        #[required]
314
        transaction_id: Uuid,
315
        #[optional]
316
        splits: Vec<FinanceEntity>,
317
        #[optional]
318
        post_date: DateTime<Utc>,
319
        #[optional]
320
        enter_date: DateTime<Utc>,
321
        #[optional]
322
        note: String,
323
        #[optional]
324
        prices: Vec<FinanceEntity>,
325
        #[optional]
326
        tags: HashMap<String, FinanceEntity>,
327
    } => {
328
        let user = User { id: user_id };
329
        let mut conn = user.get_connection().await.map_err(|err| {
330
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
331
            ConfigError::DB
332
        })?;
333

            
334
        let mut tx = conn.begin().await?;
335

            
336
        let existing = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
337
            .fetch_optional(&mut *tx)
338
            .await?
339
19
            .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
340

            
341
        let final_post_date = post_date.unwrap_or(existing.post_date);
342
        let final_enter_date = enter_date.unwrap_or(existing.enter_date);
343

            
344
        // Validate new splits if provided (before making any changes)
345
        if let Some(ref new_splits) = splits {
346
            let mut commodity_sums: std::collections::HashMap<Uuid, num_rational::Rational64> =
347
                std::collections::HashMap::new();
348

            
349
            for entity in new_splits {
350
                if let FinanceEntity::Split(split) = entity {
351
                    // Validate split belongs to this transaction
352
                    if split.tx_id != transaction_id {
353
                        return Err(CmdError::Args("Split transaction ID mismatch".to_string()));
354
                    }
355

            
356
                    let split_value =
357
                        num_rational::Rational64::new(split.value_num, split.value_denom);
358
                    *commodity_sums.entry(split.commodity_id).or_insert(
359
                        num_rational::Rational64::new(0, 1),
360
                    ) += split_value;
361
                } else {
362
                    return Err(CmdError::Args("Invalid entity type in splits".to_string()));
363
                }
364
            }
365

            
366
            // Ensure splits sum to zero (double-entry bookkeeping requirement)
367
            // For multi-currency transactions, individual currencies won't sum to zero
368
            // (they're balanced via the price table), so only validate single-currency
369
            if commodity_sums.len() == 1 {
370
                for sum in commodity_sums.values() {
371
                    if *sum != num_rational::Rational64::new(0, 1) {
372
                        return Err(CmdError::Args("Splits must sum to zero".to_string()));
373
                    }
374
                }
375
            }
376
        }
377

            
378
        // Update transaction metadata first
379
        sqlx::query_file!(
380
            "sql/update/transactions/update.sql",
381
            transaction_id,
382
            final_post_date,
383
            final_enter_date
384
        )
385
        .execute(&mut *tx)
386
        .await?;
387

            
388
        // Process splits update atomically: delete then insert
389
        if let Some(new_splits) = splits {
390
            // Delete existing split_tags, splits and their associated prices
391
            sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
392
                .execute(&mut *tx)
393
                .await?;
394

            
395
            sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
396
                .execute(&mut *tx)
397
                .await?;
398

            
399
            sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
400
                .execute(&mut *tx)
401
                .await?;
402

            
403
            // Insert new splits
404
            for entity in new_splits {
405
                if let FinanceEntity::Split(split) = entity {
406
                    sqlx::query_file!(
407
                        "sql/insert/splits/split.sql",
408
                        split.id,
409
                        split.tx_id,
410
                        split.account_id,
411
                        split.commodity_id,
412
                        split.reconcile_state,
413
                        split.reconcile_date,
414
                        split.value_num,
415
                        split.value_denom,
416
                        split.lot_id
417
                    )
418
                    .execute(&mut *tx)
419
                    .await?;
420
                }
421
            }
422
        }
423

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

            
435
        // Validate tags if provided
436
        if let Some(ref new_tags) = tags {
437
            for entity in new_tags.values() {
438
                if let FinanceEntity::Tag(_) = entity {
439
                    // Tag validation could be added here
440
                } else {
441
                    return Err(CmdError::Args("Invalid entity type in tags".to_string()));
442
                }
443
            }
444
        }
445

            
446
        // Process prices update atomically
447
        if let Some(new_prices) = prices {
448
            for entity in new_prices {
449
                if let FinanceEntity::Price(price) = entity {
450
                    sqlx::query_file!(
451
                        "sql/insert/prices/price.sql",
452
                        price.id,
453
                        price.commodity_id,
454
                        price.currency_id,
455
                        price.commodity_split,
456
                        price.currency_split,
457
                        price.date,
458
                        price.value_num,
459
                        price.value_denom
460
                    )
461
                    .execute(&mut *tx)
462
                    .await?;
463
                }
464
            }
465
        }
466

            
467
        // Process tags update atomically: delete then insert
468
        if let Some(new_tags) = tags {
469
            sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
470
                .execute(&mut *tx)
471
                .await?;
472

            
473
            for (_, entity) in new_tags {
474
                if let FinanceEntity::Tag(tag) = entity {
475
                    sqlx::query_file!(
476
                        "sql/insert/transaction_tags/transaction_tag.sql",
477
                        transaction_id,
478
                        tag.id
479
                    )
480
                    .execute(&mut *tx)
481
                    .await?;
482
                }
483
            }
484
        }
485

            
486
        // Handle note field by creating/updating note tag
487
        if let Some(note_value) = note {
488
            // First delete any existing note tag for this transaction
489
            sqlx::query!("DELETE FROM transaction_tags WHERE tx_id = $1 AND tag_id IN (SELECT id FROM tags WHERE tag_name = 'note')", transaction_id)
490
                .execute(&mut *tx)
491
                .await?;
492

            
493
            if !note_value.trim().is_empty() {
494
                let note_tag_id = Tag {
495
                    id: Uuid::new_v4(),
496
                    tag_name: "note".to_string(),
497
                    tag_value: note_value,
498
                    description: None,
499
                }
500
                .commit(&mut *tx)
501
                .await?;
502

            
503
                sqlx::query_file!(
504
                    "sql/insert/transaction_tags/transaction_tag.sql",
505
                    transaction_id,
506
                    note_tag_id
507
                )
508
                .execute(&mut *tx)
509
                .await?;
510
            }
511
        }
512

            
513
        tx.commit().await?;
514

            
515
        let updated_transaction = Transaction {
516
            id: transaction_id,
517
            post_date: final_post_date,
518
            enter_date: final_enter_date,
519
        };
520

            
521
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(updated_transaction))))
522
    }
523
193
}
524

            
525
command! {
526
    DeleteTransaction {
527
        #[required]
528
        user_id: Uuid,
529
        #[required]
530
        transaction_id: Uuid,
531
    } => {
532
        let user = User { id: user_id };
533
        let mut conn = user.get_connection().await.map_err(|err| {
534
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
535
            ConfigError::DB
536
        })?;
537

            
538
        sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
539
            .fetch_optional(&mut *conn)
540
            .await?
541
19
            .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
542

            
543
        let mut tx = conn.begin().await?;
544

            
545
        let tag_ids_to_check: Vec<Uuid> = sqlx::query_file!(
546
            "sql/select/tags/by_transaction_and_splits.sql",
547
            transaction_id
548
        )
549
        .fetch_all(&mut *tx)
550
        .await?
551
        .into_iter()
552
        .filter_map(|row| row.tag_id)
553
        .collect();
554

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

            
559
        sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
560
            .execute(&mut *tx)
561
            .await?;
562

            
563
        sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
564
            .execute(&mut *tx)
565
            .await?;
566

            
567
        for tag_id in tag_ids_to_check {
568
            let is_orphaned = sqlx::query_file!("sql/check/tags/is_orphaned.sql", tag_id)
569
                .fetch_one(&mut *tx)
570
                .await?
571
                .is_orphaned
572
                .unwrap_or(false);
573

            
574
            if is_orphaned {
575
                sqlx::query_file!("sql/delete/tags/by_id.sql", tag_id)
576
                    .execute(&mut *tx)
577
                    .await?;
578
            }
579
        }
580

            
581
        sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
582
            .execute(&mut *tx)
583
            .await?;
584

            
585
        sqlx::query_file!("sql/delete/transactions/by_id.sql", transaction_id)
586
            .execute(&mut *tx)
587
            .await?;
588

            
589
        tx.commit().await?;
590

            
591
        Ok(Some(CmdResult::String("Transaction deleted successfully".to_string())))
592
    }
593
115
}
594

            
595
// Idempotent set: replace any existing (tx, tag_name) link with the
596
// supplied `tag_value`. Mirrors `SetSplitTag` and `SetAccountTag` so
597
// the script-side `set-transaction-tag` native reads symmetrically.
598
command! {
599
    SetTransactionTag {
600
        #[required]
601
        user_id: Uuid,
602
        #[required]
603
        transaction_id: Uuid,
604
        #[required]
605
        tag_name: String,
606
        #[required]
607
        tag_value: String,
608
        #[optional]
609
        description: String,
610
    } => {
611
        let user = User { id: user_id };
612
        let desc = description.and_then(|text| {
613
            if text.trim().is_empty() {
614
                None
615
            } else {
616
                Some(text)
617
            }
618
        });
619
        let tag = Tag {
620
            id: Uuid::new_v4(),
621
            tag_name,
622
            tag_value,
623
            description: desc,
624
        };
625
        user.set_transaction_tag(transaction_id, &tag)
626
            .await
627
            .map_err(|err| {
628
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
629
                CmdError::Args(format!("{err:?}"))
630
            })?;
631
        Ok(Some(CmdResult::String("ok".to_string())))
632
    }
633
}
634

            
635
// Looks up a single tag value by name on a transaction. Returns
636
// `CmdResult::String("")` when absent (matches `GetSplitTag`).
637
command! {
638
    GetTransactionTag {
639
        #[required]
640
        user_id: Uuid,
641
        #[required]
642
        transaction_id: Uuid,
643
        #[required]
644
        tag_name: String,
645
    } => {
646
        let user = User { id: user_id };
647
        let tags = user
648
            .get_transaction_tags(transaction_id)
649
            .await
650
            .map_err(|err| {
651
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
652
                CmdError::Args(format!("{err:?}"))
653
            })?;
654
        let value = tags
655
            .into_iter()
656
            .find(|t| t.tag_name == tag_name)
657
            .map(|t| t.tag_value)
658
            .unwrap_or_default();
659
        Ok(Some(CmdResult::String(value)))
660
    }
661
}
662

            
663
#[cfg(test)]
664
mod command_tests {
665
    use super::*;
666
    use crate::{
667
        command::{account::CreateAccount, commodity::CreateCommodity},
668
        db::DB_POOL,
669
    };
670
    use chrono::Duration;
671
    use finance::{account::Account, price::Price, split::Split};
672
    use sqlx::PgPool;
673
    use supp_macro::local_db_sqlx_test;
674
    use tokio::sync::OnceCell;
675

            
676
    /// Context for keeping environment intact
677
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
678
    static USER: OnceCell<User> = OnceCell::const_new();
679

            
680
17
    async fn setup() {
681
17
        CONTEXT
682
17
            .get_or_init(|| async {
683
                #[cfg(feature = "testlog")]
684
1
                let _ = env_logger::builder()
685
1
                    .is_test(true)
686
1
                    .filter_level(log::LevelFilter::Trace)
687
1
                    .try_init();
688
2
            })
689
17
            .await;
690
17
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
691
17
            .await;
692
17
    }
693

            
694
    #[local_db_sqlx_test]
695
    async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
696
        let user = USER.get().unwrap();
697
        user.commit()
698
            .await
699
            .expect("Failed to commit user to database");
700

            
701
        // First create a commodity
702
        let commodity_result = CreateCommodity::new()
703
            .symbol("TST".to_string())
704
            .name("Test Commodity".to_string())
705
            .user_id(user.id)
706
            .run()
707
            .await?;
708

            
709
        // Get the commodity ID
710
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
711
            uuid::Uuid::parse_str(&id)?
712
        } else {
713
            panic!("Expected commodity ID string result");
714
        };
715

            
716
        // Create two accounts
717
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
718
            CreateAccount::new()
719
                .name("Account 1".to_string())
720
                .user_id(user.id)
721
                .run()
722
                .await?
723
        {
724
            account
725
        } else {
726
            panic!("Expected account entity result");
727
        };
728

            
729
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
730
            CreateAccount::new()
731
                .name("Account 2".to_string())
732
                .user_id(user.id)
733
                .run()
734
                .await?
735
        {
736
            account
737
        } else {
738
            panic!("Expected account entity result");
739
        };
740

            
741
        let tx_id = Uuid::new_v4();
742

            
743
        // Create splits
744
        let split1 = Split::builder()
745
            .id(Uuid::new_v4())
746
            .tx_id(tx_id)
747
            .account_id(account1.id)
748
            .commodity_id(commodity_id)
749
            .value_num(100)
750
            .value_denom(1)
751
            .build()?;
752

            
753
        let split2 = Split::builder()
754
            .id(Uuid::new_v4())
755
            .tx_id(tx_id)
756
            .account_id(account2.id)
757
            .commodity_id(commodity_id)
758
            .value_num(-100)
759
            .value_denom(1)
760
            .build()?;
761

            
762
        // Create transaction with splits
763
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
764
        let now = Utc::now();
765

            
766
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) = CreateTransaction::new()
767
            .user_id(user.id)
768
            .splits(splits)
769
            .id(tx_id)
770
            .post_date(now)
771
            .enter_date(now)
772
            .run()
773
            .await?
774
        {
775
            assert!(!tx.id.is_nil());
776

            
777
            // Verify splits were created
778
            let mut conn = user.get_connection().await?;
779
            let splits = sqlx::query_file!("sql/count/splits/by_transaction.sql", tx.id)
780
                .fetch_one(&mut *conn)
781
                .await?;
782
            assert_eq!(splits.count, Some(2));
783
        } else {
784
            panic!("Expected transaction entity result");
785
        }
786
    }
787
    #[local_db_sqlx_test]
788
    async fn test_list_transactions_empty(pool: PgPool) -> anyhow::Result<()> {
789
        let user = USER.get().unwrap();
790
        user.commit()
791
            .await
792
            .expect("Failed to commit user to database");
793

            
794
        if let Some(CmdResult::TaggedEntities {
795
            entities,
796
            pagination: Some(pagination),
797
        }) = ListTransactions::new().user_id(user.id).run().await?
798
        {
799
            assert!(
800
                entities.is_empty(),
801
                "Expected no transactions in empty database"
802
            );
803
            assert_eq!(pagination.total_count, 0);
804
            assert_eq!(pagination.limit, 20);
805
            assert_eq!(pagination.offset, 0);
806
            assert!(!pagination.has_more);
807
        } else {
808
            panic!("Expected TaggedEntities result with pagination");
809
        }
810
    }
811

            
812
    #[local_db_sqlx_test]
813
    async fn test_list_transactions_with_data(pool: PgPool) -> anyhow::Result<()> {
814
        let user = USER.get().unwrap();
815
        user.commit()
816
            .await
817
            .expect("Failed to commit user to database");
818

            
819
        // First create a commodity
820
        let commodity_result = CreateCommodity::new()
821
            .symbol("TST".to_string())
822
            .name("Test Commodity".to_string())
823
            .user_id(user.id)
824
            .run()
825
            .await?;
826

            
827
        // Get the commodity ID
828
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
829
            uuid::Uuid::parse_str(&id)?
830
        } else {
831
            panic!("Expected commodity ID string result");
832
        };
833

            
834
        // Create two accounts
835
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
836
            CreateAccount::new()
837
                .name("Account 1".to_string())
838
                .user_id(user.id)
839
                .run()
840
                .await?
841
        {
842
            account
843
        } else {
844
            panic!("Expected account entity result");
845
        };
846

            
847
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
848
            CreateAccount::new()
849
                .name("Account 2".to_string())
850
                .user_id(user.id)
851
                .run()
852
                .await?
853
        {
854
            account
855
        } else {
856
            panic!("Expected account entity result");
857
        };
858

            
859
        // Create a transaction between the accounts
860
        let tx_id = Uuid::new_v4();
861
        let now = Utc::now();
862

            
863
        let split1 = Split {
864
            id: Uuid::new_v4(),
865
            tx_id,
866
            account_id: account1.id,
867
            commodity_id,
868
            value_num: -100,
869
            value_denom: 1,
870
            reconcile_state: None,
871
            reconcile_date: None,
872
            lot_id: None,
873
        };
874

            
875
        let split2 = Split {
876
            id: Uuid::new_v4(),
877
            tx_id,
878
            account_id: account2.id,
879
            commodity_id,
880
            value_num: 100,
881
            value_denom: 1,
882
            reconcile_state: None,
883
            reconcile_date: None,
884
            lot_id: None,
885
        };
886

            
887
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
888
        CreateTransaction::new()
889
            .user_id(user.id)
890
            .splits(splits)
891
            .id(tx_id)
892
            .post_date(now)
893
            .enter_date(now)
894
            .run()
895
            .await?;
896

            
897
        // List all transactions
898
        if let Some(CmdResult::TaggedEntities {
899
            entities,
900
            pagination: Some(pagination),
901
        }) = ListTransactions::new().user_id(user.id).run().await?
902
        {
903
            assert_eq!(entities.len(), 1, "Expected one transaction");
904
            assert_eq!(pagination.total_count, 1);
905

            
906
            let (entity, _tags) = &entities[0];
907
            if let FinanceEntity::Transaction(tx) = entity {
908
                assert_eq!(tx.id, tx_id);
909
            } else {
910
                panic!("Expected Transaction entity");
911
            }
912
        } else {
913
            panic!("Expected TaggedEntities result with pagination");
914
        }
915

            
916
        // List transactions filtered by account
917
        if let Some(CmdResult::TaggedEntities { entities, .. }) = ListTransactions::new()
918
            .user_id(user.id)
919
            .account(account1.id)
920
            .run()
921
            .await?
922
        {
923
            assert_eq!(entities.len(), 1, "Expected one transaction for account1");
924
        } else {
925
            panic!("Expected TaggedEntities result");
926
        }
927

            
928
        // List transactions for non-existent account
929
        if let Some(CmdResult::TaggedEntities { entities, .. }) = ListTransactions::new()
930
            .user_id(user.id)
931
            .account(Uuid::new_v4())
932
            .run()
933
            .await?
934
        {
935
            assert_eq!(
936
                entities.len(),
937
                0,
938
                "Expected no transactions for non-existent account"
939
            );
940
        } else {
941
            panic!("Expected TaggedEntities result");
942
        }
943
    }
944

            
945
    #[local_db_sqlx_test]
946
    async fn test_get_transaction(pool: PgPool) -> anyhow::Result<()> {
947
        let user = USER.get().unwrap();
948
        user.commit()
949
            .await
950
            .expect("Failed to commit user to database");
951

            
952
        // First create a commodity
953
        let commodity_result = CreateCommodity::new()
954
            .symbol("TST".to_string())
955
            .name("Test Commodity".to_string())
956
            .user_id(user.id)
957
            .run()
958
            .await?;
959

            
960
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
961
            uuid::Uuid::parse_str(&id)?
962
        } else {
963
            panic!("Expected commodity ID string result");
964
        };
965

            
966
        // Create two accounts
967
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
968
            CreateAccount::new()
969
                .name("Account 1".to_string())
970
                .user_id(user.id)
971
                .run()
972
                .await?
973
        {
974
            account
975
        } else {
976
            panic!("Expected account entity result");
977
        };
978

            
979
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
980
            CreateAccount::new()
981
                .name("Account 2".to_string())
982
                .user_id(user.id)
983
                .run()
984
                .await?
985
        {
986
            account
987
        } else {
988
            panic!("Expected account entity result");
989
        };
990

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