1
use finance::{tag::Tag, transaction::Transaction};
2
use sqlx::{
3
    Acquire,
4
    types::Uuid,
5
    types::chrono::{DateTime, Utc},
6
};
7
use std::{collections::HashMap, fmt::Debug};
8
use supp_macro::command;
9

            
10
use crate::{config::ConfigError, user::User};
11

            
12
use super::{CmdError, CmdResult, FinanceEntity};
13

            
14
command! {
15
    CreateTransaction {
16
        #[required]
17
        user_id: Uuid,
18
        #[required]
19
        splits: Vec<FinanceEntity>,
20
        #[required]
21
        id: Uuid,
22
        #[required]
23
        post_date: DateTime<Utc>,
24
        #[required]
25
        enter_date: DateTime<Utc>,
26
        #[optional]
27
        prices: Vec<FinanceEntity>,
28
        #[optional]
29
        note: String,
30
    } => {
31
        // Create new transaction
32
        let tx = Transaction {
33
            id,
34
            post_date,
35
            enter_date,
36
        };
37

            
38
        let user = User { id: user_id };
39

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

            
46
        // Start transaction ticket
47
        let mut ticket = tx.enter(&mut *conn).await?;
48

            
49
        // Add splits using the ticket
50
        let mut split_refs = Vec::new();
51
        for entity in &splits {
52
            if let FinanceEntity::Split(split) = entity {
53
                split_refs.push(split);
54
            }
55
        }
56
        ticket.add_splits(&split_refs).await?;
57

            
58
        if let Some(prices) = prices {
59
            let mut price_refs = Vec::new();
60
            for entity in &prices {
61
                if let FinanceEntity::Price(price) = entity {
62
                    price_refs.push(price);
63
                }
64
            }
65
            ticket.add_conversions(&price_refs).await?;
66
        }
67

            
68
        if let Some(note) = note {
69
            let tag = Tag {
70
                id: Uuid::new_v4(),
71
                tag_name: "note".to_string(),
72
                tag_value: note,
73
                description: None,
74
            };
75

            
76
            ticket.add_tags(&[&tag]).await?;
77
        }
78

            
79
        // Commit the transaction ticket
80
        ticket.commit().await?;
81

            
82
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(tx))))
83
    }
84
346
}
85

            
86
command! {
87
    ListTransactions {
88
        #[required]
89
        user_id: Uuid,
90
        #[optional]
91
        account: Uuid,
92
    } => {
93
        let user = User { id: user_id };
94
10
        let mut conn = user.get_connection().await.map_err(|err| {
95
10
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
96
10
            ConfigError::DB
97
10
        })?;
98

            
99
        let account_uuid = account.as_ref();
100

            
101
        let transactions = sqlx::query_file!("sql/select/transactions/all.sql", account_uuid)
102
            .fetch_all(&mut *conn)
103
            .await?;
104

            
105
        let mut tagged_transactions = Vec::new();
106
        for tx_row in transactions {
107
            let transaction = Transaction {
108
                id: tx_row.id,
109
                post_date: tx_row.post_date,
110
                enter_date: tx_row.enter_date,
111
            };
112

            
113
            // Get tags for this transaction
114
            let tags: HashMap<String, FinanceEntity> =
115
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
116
                    .fetch_all(&mut *conn)
117
                    .await?
118
                    .into_iter()
119
                    .map(|row| {
120
                        (
121
                            row.tag_name.clone(),
122
                            FinanceEntity::Tag(Tag {
123
                                id: row.id,
124
                                tag_name: row.tag_name,
125
                                tag_value: row.tag_value,
126
                                description: row.description,
127
                            }),
128
                        )
129
                    })
130
                    .collect();
131

            
132
            tagged_transactions.push((FinanceEntity::Transaction(transaction), tags));
133
        }
134

            
135
        Ok(Some(CmdResult::TaggedEntities(tagged_transactions)))
136
    }
137
110
}
138

            
139
command! {
140
    GetTransaction {
141
        #[required]
142
        user_id: Uuid,
143
        #[required]
144
        transaction_id: Uuid,
145
    } => {
146
        let user = User { id: user_id };
147
        let mut conn = user.get_connection().await.map_err(|err| {
148
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
149
            ConfigError::DB
150
        })?;
151

            
152
        let tx_row = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
153
            .fetch_optional(&mut *conn)
154
            .await?;
155

            
156
        if let Some(tx_row) = tx_row {
157
            let transaction = Transaction {
158
                id: tx_row.id,
159
                post_date: tx_row.post_date,
160
                enter_date: tx_row.enter_date,
161
            };
162

            
163
            let tags: HashMap<String, FinanceEntity> =
164
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
165
                    .fetch_all(&mut *conn)
166
                    .await?
167
                    .into_iter()
168
8
                    .map(|row| {
169
8
                        (
170
8
                            row.tag_name.clone(),
171
8
                            FinanceEntity::Tag(Tag {
172
8
                                id: row.id,
173
8
                                tag_name: row.tag_name,
174
8
                                tag_value: row.tag_value,
175
8
                                description: row.description,
176
8
                            }),
177
8
                        )
178
8
                    })
179
                    .collect();
180

            
181
            Ok(Some(CmdResult::TaggedEntities(vec![(FinanceEntity::Transaction(transaction), tags)])))
182
        } else {
183
            Ok(None)
184
        }
185
    }
186
40
}
187

            
188
command! {
189
    UpdateTransaction {
190
        #[required]
191
        user_id: Uuid,
192
        #[required]
193
        transaction_id: Uuid,
194
        #[optional]
195
        splits: Vec<FinanceEntity>,
196
        #[optional]
197
        post_date: DateTime<Utc>,
198
        #[optional]
199
        enter_date: DateTime<Utc>,
200
        #[optional]
201
        note: String,
202
        #[optional]
203
        prices: Vec<FinanceEntity>,
204
        #[optional]
205
        tags: HashMap<String, FinanceEntity>,
206
    } => {
207
        let user = User { id: user_id };
208
        let mut conn = user.get_connection().await.map_err(|err| {
209
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
210
            ConfigError::DB
211
        })?;
212

            
213
        let mut tx = conn.begin().await?;
214

            
215
        let existing = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
216
            .fetch_optional(&mut *tx)
217
            .await?
218
2
            .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
219

            
220
        let final_post_date = post_date.unwrap_or(existing.post_date);
221
        let final_enter_date = enter_date.unwrap_or(existing.enter_date);
222

            
223
        // Validate new splits if provided (before making any changes)
224
        if let Some(ref new_splits) = splits {
225
            let mut commodity_sums: std::collections::HashMap<Uuid, num_rational::Rational64> =
226
                std::collections::HashMap::new();
227

            
228
            for entity in new_splits {
229
                if let FinanceEntity::Split(split) = entity {
230
                    // Validate split belongs to this transaction
231
                    if split.tx_id != transaction_id {
232
                        return Err(CmdError::Args("Split transaction ID mismatch".to_string()));
233
                    }
234

            
235
                    let split_value =
236
                        num_rational::Rational64::new(split.value_num, split.value_denom);
237
                    *commodity_sums.entry(split.commodity_id).or_insert(
238
                        num_rational::Rational64::new(0, 1),
239
                    ) += split_value;
240
                } else {
241
                    return Err(CmdError::Args("Invalid entity type in splits".to_string()));
242
                }
243
            }
244

            
245
            // Ensure splits sum to zero (double-entry bookkeeping requirement)
246
            // For multi-currency transactions, individual currencies won't sum to zero
247
            // (they're balanced via the price table), so only validate single-currency
248
            if commodity_sums.len() == 1 {
249
                for sum in commodity_sums.values() {
250
                    if *sum != num_rational::Rational64::new(0, 1) {
251
                        return Err(CmdError::Args("Splits must sum to zero".to_string()));
252
                    }
253
                }
254
            }
255
        }
256

            
257
        // Update transaction metadata first
258
        sqlx::query_file!(
259
            "sql/update/transactions/update.sql",
260
            transaction_id,
261
            final_post_date,
262
            final_enter_date
263
        )
264
        .execute(&mut *tx)
265
        .await?;
266

            
267
        // Process splits update atomically: delete then insert
268
        if let Some(new_splits) = splits {
269
            // Delete existing split_tags, splits and their associated prices
270
            sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
271
                .execute(&mut *tx)
272
                .await?;
273

            
274
            sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
275
                .execute(&mut *tx)
276
                .await?;
277

            
278
            sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
279
                .execute(&mut *tx)
280
                .await?;
281

            
282
            // Insert new splits
283
            for entity in new_splits {
284
                if let FinanceEntity::Split(split) = entity {
285
                    sqlx::query_file!(
286
                        "sql/insert/splits/split.sql",
287
                        split.id,
288
                        split.tx_id,
289
                        split.account_id,
290
                        split.commodity_id,
291
                        split.reconcile_state,
292
                        split.reconcile_date,
293
                        split.value_num,
294
                        split.value_denom,
295
                        split.lot_id
296
                    )
297
                    .execute(&mut *tx)
298
                    .await?;
299
                }
300
            }
301
        }
302

            
303
        // Validate and process prices if provided
304
        if let Some(ref new_prices) = prices {
305
            for entity in new_prices {
306
                if let FinanceEntity::Price(_) = entity {
307
                    // Price validation could be added here
308
                } else {
309
                    return Err(CmdError::Args("Invalid entity type in prices".to_string()));
310
                }
311
            }
312
        }
313

            
314
        // Validate tags if provided
315
        if let Some(ref new_tags) = tags {
316
            for entity in new_tags.values() {
317
                if let FinanceEntity::Tag(_) = entity {
318
                    // Tag validation could be added here
319
                } else {
320
                    return Err(CmdError::Args("Invalid entity type in tags".to_string()));
321
                }
322
            }
323
        }
324

            
325
        // Process prices update atomically
326
        if let Some(new_prices) = prices {
327
            for entity in new_prices {
328
                if let FinanceEntity::Price(price) = entity {
329
                    sqlx::query_file!(
330
                        "sql/insert/prices/price.sql",
331
                        price.id,
332
                        price.commodity_id,
333
                        price.currency_id,
334
                        price.commodity_split,
335
                        price.currency_split,
336
                        price.date,
337
                        price.value_num,
338
                        price.value_denom
339
                    )
340
                    .execute(&mut *tx)
341
                    .await?;
342
                }
343
            }
344
        }
345

            
346
        // Process tags update atomically: delete then insert
347
        if let Some(new_tags) = tags {
348
            sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
349
                .execute(&mut *tx)
350
                .await?;
351

            
352
            for (_, entity) in new_tags {
353
                if let FinanceEntity::Tag(tag) = entity {
354
                    sqlx::query_file!(
355
                        "sql/insert/transaction_tags/transaction_tag.sql",
356
                        transaction_id,
357
                        tag.id
358
                    )
359
                    .execute(&mut *tx)
360
                    .await?;
361
                }
362
            }
363
        }
364

            
365
        // Handle note field by creating/updating note tag
366
        if let Some(note_value) = note {
367
            // First delete any existing note tag for this transaction
368
            sqlx::query!("DELETE FROM transaction_tags WHERE tx_id = $1 AND tag_id IN (SELECT id FROM tags WHERE tag_name = 'note')", transaction_id)
369
                .execute(&mut *tx)
370
                .await?;
371

            
372
            if !note_value.trim().is_empty() {
373
                // Create a new note tag
374
                let note_tag = Tag {
375
                    id: Uuid::new_v4(),
376
                    tag_name: "note".to_string(),
377
                    tag_value: note_value,
378
                    description: None,
379
                };
380

            
381
                // Insert the tag into the tags table
382
                sqlx::query_file!(
383
                    "sql/insert/tags/tag.sql",
384
                    note_tag.id,
385
                    note_tag.tag_name,
386
                    note_tag.tag_value,
387
                    note_tag.description
388
                )
389
                .execute(&mut *tx)
390
                .await?;
391

            
392
                // Link the tag to the transaction
393
                sqlx::query_file!(
394
                    "sql/insert/transaction_tags/transaction_tag.sql",
395
                    transaction_id,
396
                    note_tag.id
397
                )
398
                .execute(&mut *tx)
399
                .await?;
400
            }
401
        }
402

            
403
        tx.commit().await?;
404

            
405
        let updated_transaction = Transaction {
406
            id: transaction_id,
407
            post_date: final_post_date,
408
            enter_date: final_enter_date,
409
        };
410

            
411
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(updated_transaction))))
412
    }
413
134
}
414

            
415
command! {
416
    DeleteTransaction {
417
        #[required]
418
        user_id: Uuid,
419
        #[required]
420
        transaction_id: Uuid,
421
    } => {
422
        let user = User { id: user_id };
423
        let mut conn = user.get_connection().await.map_err(|err| {
424
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
425
            ConfigError::DB
426
        })?;
427

            
428
        sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
429
            .fetch_optional(&mut *conn)
430
            .await?
431
2
            .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
432

            
433
        let mut tx = conn.begin().await?;
434

            
435
        let tag_ids_to_check: Vec<Uuid> = sqlx::query_file!(
436
            "sql/select/tags/by_transaction_and_splits.sql",
437
            transaction_id
438
        )
439
        .fetch_all(&mut *tx)
440
        .await?
441
        .into_iter()
442
        .filter_map(|row| row.tag_id)
443
        .collect();
444

            
445
        sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
446
            .execute(&mut *tx)
447
            .await?;
448

            
449
        sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
450
            .execute(&mut *tx)
451
            .await?;
452

            
453
        sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
454
            .execute(&mut *tx)
455
            .await?;
456

            
457
        for tag_id in tag_ids_to_check {
458
            let is_orphaned = sqlx::query_file!("sql/check/tags/is_orphaned.sql", tag_id)
459
                .fetch_one(&mut *tx)
460
                .await?
461
                .is_orphaned
462
                .unwrap_or(false);
463

            
464
            if is_orphaned {
465
                sqlx::query_file!("sql/delete/tags/by_id.sql", tag_id)
466
                    .execute(&mut *tx)
467
                    .await?;
468
            }
469
        }
470

            
471
        sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
472
            .execute(&mut *tx)
473
            .await?;
474

            
475
        sqlx::query_file!("sql/delete/transactions/by_id.sql", transaction_id)
476
            .execute(&mut *tx)
477
            .await?;
478

            
479
        tx.commit().await?;
480

            
481
        Ok(Some(CmdResult::String("Transaction deleted successfully".to_string())))
482
    }
483
32
}
484

            
485
#[cfg(test)]
486
mod command_tests {
487
    use super::*;
488
    use crate::{
489
        command::{account::CreateAccount, commodity::CreateCommodity},
490
        db::DB_POOL,
491
    };
492
    use finance::{account::Account, price::Price, split::Split};
493
    use sqlx::PgPool;
494
    use supp_macro::local_db_sqlx_test;
495
    use tokio::sync::OnceCell;
496

            
497
    /// Context for keeping environment intact
498
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
499
    static USER: OnceCell<User> = OnceCell::const_new();
500

            
501
33
    async fn setup() {
502
22
        CONTEXT
503
22
            .get_or_init(|| async {
504
                #[cfg(feature = "testlog")]
505
2
                let _ = env_logger::builder()
506
2
                    .is_test(true)
507
2
                    .filter_level(log::LevelFilter::Trace)
508
2
                    .try_init();
509
4
            })
510
22
            .await;
511
22
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
512
22
            .await;
513
22
    }
514

            
515
    #[local_db_sqlx_test]
516
    async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
517
        let user = USER.get().unwrap();
518
        user.commit()
519
            .await
520
            .expect("Failed to commit user to database");
521

            
522
        // First create a commodity
523
        let commodity_result = CreateCommodity::new()
524
            .fraction(1.into())
525
            .symbol("TST".to_string())
526
            .name("Test Commodity".to_string())
527
            .user_id(user.id)
528
            .run()
529
            .await?;
530

            
531
        // Get the commodity ID
532
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
533
            uuid::Uuid::parse_str(&id)?
534
        } else {
535
            panic!("Expected commodity ID string result");
536
        };
537

            
538
        // Create two accounts
539
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
540
            CreateAccount::new()
541
                .name("Account 1".to_string())
542
                .user_id(user.id)
543
                .run()
544
                .await?
545
        {
546
            account
547
        } else {
548
            panic!("Expected account entity result");
549
        };
550

            
551
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
552
            CreateAccount::new()
553
                .name("Account 2".to_string())
554
                .user_id(user.id)
555
                .run()
556
                .await?
557
        {
558
            account
559
        } else {
560
            panic!("Expected account entity result");
561
        };
562

            
563
        let tx_id = Uuid::new_v4();
564

            
565
        // Create splits
566
        let split1 = Split::builder()
567
            .id(Uuid::new_v4())
568
            .tx_id(tx_id)
569
            .account_id(account1.id)
570
            .commodity_id(commodity_id)
571
            .value_num(100)
572
            .value_denom(1)
573
            .build()?;
574

            
575
        let split2 = Split::builder()
576
            .id(Uuid::new_v4())
577
            .tx_id(tx_id)
578
            .account_id(account2.id)
579
            .commodity_id(commodity_id)
580
            .value_num(-100)
581
            .value_denom(1)
582
            .build()?;
583

            
584
        // Create transaction with splits
585
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
586
        let now = Utc::now();
587

            
588
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) = CreateTransaction::new()
589
            .user_id(user.id)
590
            .splits(splits)
591
            .id(tx_id)
592
            .post_date(now)
593
            .enter_date(now)
594
            .run()
595
            .await?
596
        {
597
            assert!(!tx.id.is_nil());
598

            
599
            // Verify splits were created
600
            let mut conn = user.get_connection().await?;
601
            let splits = sqlx::query_file!("sql/count/splits/by_transaction.sql", tx.id)
602
                .fetch_one(&mut *conn)
603
                .await?;
604
            assert_eq!(splits.count, Some(2));
605
        } else {
606
            panic!("Expected transaction entity result");
607
        }
608
    }
609
    #[local_db_sqlx_test]
610
    async fn test_list_transactions_empty(pool: PgPool) -> anyhow::Result<()> {
611
        let user = USER.get().unwrap();
612
        user.commit()
613
            .await
614
            .expect("Failed to commit user to database");
615

            
616
        if let Some(CmdResult::TaggedEntities(entities)) =
617
            ListTransactions::new().user_id(user.id).run().await?
618
        {
619
            assert!(
620
                entities.is_empty(),
621
                "Expected no transactions in empty database"
622
            );
623
        } else {
624
            panic!("Expected TaggedEntities result");
625
        }
626
    }
627

            
628
    #[local_db_sqlx_test]
629
    async fn test_list_transactions_with_data(pool: PgPool) -> anyhow::Result<()> {
630
        let user = USER.get().unwrap();
631
        user.commit()
632
            .await
633
            .expect("Failed to commit user to database");
634

            
635
        // First create a commodity
636
        let commodity_result = CreateCommodity::new()
637
            .fraction(1.into())
638
            .symbol("TST".to_string())
639
            .name("Test Commodity".to_string())
640
            .user_id(user.id)
641
            .run()
642
            .await?;
643

            
644
        // Get the commodity ID
645
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
646
            uuid::Uuid::parse_str(&id)?
647
        } else {
648
            panic!("Expected commodity ID string result");
649
        };
650

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

            
664
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
665
            CreateAccount::new()
666
                .name("Account 2".to_string())
667
                .user_id(user.id)
668
                .run()
669
                .await?
670
        {
671
            account
672
        } else {
673
            panic!("Expected account entity result");
674
        };
675

            
676
        // Create a transaction between the accounts
677
        let tx_id = Uuid::new_v4();
678
        let now = Utc::now();
679

            
680
        let split1 = Split {
681
            id: Uuid::new_v4(),
682
            tx_id,
683
            account_id: account1.id,
684
            commodity_id,
685
            value_num: -100,
686
            value_denom: 1,
687
            reconcile_state: None,
688
            reconcile_date: None,
689
            lot_id: None,
690
        };
691

            
692
        let split2 = Split {
693
            id: Uuid::new_v4(),
694
            tx_id,
695
            account_id: account2.id,
696
            commodity_id,
697
            value_num: 100,
698
            value_denom: 1,
699
            reconcile_state: None,
700
            reconcile_date: None,
701
            lot_id: None,
702
        };
703

            
704
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
705
        CreateTransaction::new()
706
            .user_id(user.id)
707
            .splits(splits)
708
            .id(tx_id)
709
            .post_date(now)
710
            .enter_date(now)
711
            .run()
712
            .await?;
713

            
714
        // List all transactions
715
        if let Some(CmdResult::TaggedEntities(entities)) =
716
            ListTransactions::new().user_id(user.id).run().await?
717
        {
718
            assert_eq!(entities.len(), 1, "Expected one transaction");
719

            
720
            let (entity, _tags) = &entities[0];
721
            if let FinanceEntity::Transaction(tx) = entity {
722
                assert_eq!(tx.id, tx_id);
723
            } else {
724
                panic!("Expected Transaction entity");
725
            }
726
        } else {
727
            panic!("Expected TaggedEntities result");
728
        }
729

            
730
        // List transactions filtered by account
731
        if let Some(CmdResult::TaggedEntities(entities)) = ListTransactions::new()
732
            .user_id(user.id)
733
            .account(account1.id)
734
            .run()
735
            .await?
736
        {
737
            assert_eq!(entities.len(), 1, "Expected one transaction for account1");
738
        } else {
739
            panic!("Expected TaggedEntities result");
740
        }
741

            
742
        // List transactions for non-existent account
743
        if let Some(CmdResult::TaggedEntities(entities)) = ListTransactions::new()
744
            .user_id(user.id)
745
            .account(Uuid::new_v4())
746
            .run()
747
            .await?
748
        {
749
            assert_eq!(
750
                entities.len(),
751
                0,
752
                "Expected no transactions for non-existent account"
753
            );
754
        } else {
755
            panic!("Expected TaggedEntities result");
756
        }
757
    }
758

            
759
    #[local_db_sqlx_test]
760
    async fn test_get_transaction(pool: PgPool) -> anyhow::Result<()> {
761
        let user = USER.get().unwrap();
762
        user.commit()
763
            .await
764
            .expect("Failed to commit user to database");
765

            
766
        // First create a commodity
767
        let commodity_result = CreateCommodity::new()
768
            .fraction(1.into())
769
            .symbol("TST".to_string())
770
            .name("Test Commodity".to_string())
771
            .user_id(user.id)
772
            .run()
773
            .await?;
774

            
775
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
776
            uuid::Uuid::parse_str(&id)?
777
        } else {
778
            panic!("Expected commodity ID string result");
779
        };
780

            
781
        // Create two accounts
782
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
783
            CreateAccount::new()
784
                .name("Account 1".to_string())
785
                .user_id(user.id)
786
                .run()
787
                .await?
788
        {
789
            account
790
        } else {
791
            panic!("Expected account entity result");
792
        };
793

            
794
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
795
            CreateAccount::new()
796
                .name("Account 2".to_string())
797
                .user_id(user.id)
798
                .run()
799
                .await?
800
        {
801
            account
802
        } else {
803
            panic!("Expected account entity result");
804
        };
805

            
806
        // Create a transaction
807
        let tx_id = Uuid::new_v4();
808
        let split1 = Split {
809
            id: Uuid::new_v4(),
810
            tx_id,
811
            account_id: account1.id,
812
            commodity_id,
813
            value_num: -100,
814
            value_denom: 1,
815
            reconcile_state: None,
816
            reconcile_date: None,
817
            lot_id: None,
818
        };
819
        let split2 = Split {
820
            id: Uuid::new_v4(),
821
            tx_id,
822
            account_id: account2.id,
823
            commodity_id,
824
            value_num: 100,
825
            value_denom: 1,
826
            reconcile_state: None,
827
            reconcile_date: None,
828
            lot_id: None,
829
        };
830
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
831
        let now = Utc::now();
832

            
833
        CreateTransaction::new()
834
            .user_id(user.id)
835
            .splits(splits)
836
            .id(tx_id)
837
            .post_date(now)
838
            .enter_date(now)
839
            .note("Test transaction".to_string())
840
            .run()
841
            .await?;
842

            
843
        // Test GetTransaction
844
        if let Some(CmdResult::TaggedEntities(entities)) = GetTransaction::new()
845
            .user_id(user.id)
846
            .transaction_id(tx_id)
847
            .run()
848
            .await?
849
        {
850
            assert_eq!(entities.len(), 1, "Expected one transaction");
851
            let (entity, _tags) = &entities[0];
852
            if let FinanceEntity::Transaction(tx) = entity {
853
                assert_eq!(tx.id, tx_id);
854
            } else {
855
                panic!("Expected Transaction entity");
856
            }
857
        } else {
858
            panic!("Expected TaggedEntities result");
859
        }
860

            
861
        // Test GetTransaction with non-existent ID
862
        let result = GetTransaction::new()
863
            .user_id(user.id)
864
            .transaction_id(Uuid::new_v4())
865
            .run()
866
            .await?;
867
        assert!(
868
            result.is_none(),
869
            "Expected None for non-existent transaction"
870
        );
871
    }
872

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

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

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

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

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

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

            
947
        CreateTransaction::new()
948
            .user_id(user.id)
949
            .splits(splits)
950
            .id(tx_id)
951
            .post_date(now)
952
            .enter_date(now)
953
            .note("Original note".to_string())
954
            .run()
955
            .await?;
956

            
957
        // Test UpdateTransaction with only note change
958
        let new_note = "Updated note".to_string();
959
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
960
            UpdateTransaction::new()
961
                .user_id(user.id)
962
                .transaction_id(tx_id)
963
                .note(new_note.clone())
964
                .run()
965
                .await?
966
        {
967
            assert_eq!(updated_tx.id, tx_id);
968
        } else {
969
            panic!("Expected Transaction entity result");
970
        }
971

            
972
        // Test UpdateTransaction with new splits
973
        let new_split1 = Split {
974
            id: Uuid::new_v4(),
975
            tx_id,
976
            account_id: account1.id,
977
            commodity_id,
978
            value_num: -200,
979
            value_denom: 1,
980
            reconcile_state: None,
981
            reconcile_date: None,
982
            lot_id: None,
983
        };
984
        let new_split2 = Split {
985
            id: Uuid::new_v4(),
986
            tx_id,
987
            account_id: account2.id,
988
            commodity_id,
989
            value_num: 200,
990
            value_denom: 1,
991
            reconcile_state: None,
992
            reconcile_date: None,
993
            lot_id: None,
994
        };
995
        let new_splits = vec![
996
            FinanceEntity::Split(new_split1),
997
            FinanceEntity::Split(new_split2),
998
        ];
999

            
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
            UpdateTransaction::new()
                .user_id(user.id)
                .transaction_id(tx_id)
                .splits(new_splits)
                .run()
                .await?
        {
            assert_eq!(updated_tx.id, tx_id);
        } else {
            panic!("Expected Transaction entity result");
        }
        // Test UpdateTransaction atomicity: unbalanced splits should fail
        let unbalanced_split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100, // This doesn't balance with split2
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let unbalanced_split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 50, // Should be 100 to balance
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let unbalanced_splits = vec![
            FinanceEntity::Split(unbalanced_split1),
            FinanceEntity::Split(unbalanced_split2),
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .splits(unbalanced_splits)
            .run()
            .await;
        assert!(result.is_err(), "Expected error for unbalanced splits");
        // Verify original transaction is unchanged after failed update
        if let Some(CmdResult::TaggedEntities(entities)) = GetTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 1, "Expected one transaction");
            // Transaction should still exist and be unchanged
        } else {
            panic!("Expected transaction to still exist after failed update");
        }
        // Test UpdateTransaction with non-existent transaction
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(Uuid::new_v4())
            .note("Should fail".to_string())
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for non-existent transaction"
        );
    }
    #[local_db_sqlx_test]
    async fn test_update_transaction_atomicity(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // First create a commodity
        let commodity_result = CreateCommodity::new()
            .fraction(1.into())
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        // Create two accounts
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        // Create a transaction
        let tx_id = Uuid::new_v4();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .note("Original transaction".to_string())
            .run()
            .await?;
        // Test 1: Split transaction ID mismatch validation
        let wrong_tx_id = Uuid::new_v4();
        let invalid_split = Split {
            id: Uuid::new_v4(),
            tx_id: wrong_tx_id, // Wrong transaction ID
            account_id: account1.id,
            commodity_id,
            value_num: -50,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let valid_split = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 50,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let mismatched_splits = vec![
            FinanceEntity::Split(invalid_split),
            FinanceEntity::Split(valid_split),
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .splits(mismatched_splits)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for split transaction ID mismatch"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Split transaction ID mismatch"));
        } else {
            panic!("Expected CmdError::Args with transaction ID mismatch message");
        }
        // Verify original transaction is unchanged
        if let Some(CmdResult::TaggedEntities(entities)) = GetTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 1, "Expected one transaction");
        } else {
            panic!("Expected transaction to still exist after failed update");
        }
        // Test 2: Invalid entity type in splits
        let invalid_splits = vec![
            FinanceEntity::Account(Account {
                id: account1.id,
                parent: account1.parent,
            }), // Wrong entity type
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .splits(invalid_splits)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for invalid entity type in splits"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Invalid entity type in splits"));
        } else {
            panic!("Expected CmdError::Args with invalid entity type message");
        }
        // Test 3: Invalid entity type in prices
        let invalid_prices = vec![
            FinanceEntity::Account(Account {
                id: account1.id,
                parent: account1.parent,
            }), // Wrong entity type
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .prices(invalid_prices)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for invalid entity type in prices"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Invalid entity type in prices"));
        } else {
            panic!("Expected CmdError::Args with invalid entity type message");
        }
        // Test 4: Invalid entity type in tags
        let mut invalid_tags = HashMap::new();
        invalid_tags.insert(
            "test".to_string(),
            FinanceEntity::Account(Account {
                id: account1.id,
                parent: account1.parent,
            }),
        );
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .tags(invalid_tags)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for invalid entity type in tags"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Invalid entity type in tags"));
        } else {
            panic!("Expected CmdError::Args with invalid entity type message");
        }
        // Test 5: Database rollback verification - count splits before and after failed update
        let mut conn = user.get_connection().await?;
        let initial_split_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        // Try an update that will fail during split insertion (invalid account ID)
        let invalid_account_split = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: Uuid::new_v4(), // Non-existent account
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let balancing_split = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let failing_splits = vec![
            FinanceEntity::Split(invalid_account_split),
            FinanceEntity::Split(balancing_split),
        ];
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .splits(failing_splits)
            .run()
            .await;
        assert!(result.is_err(), "Expected error for non-existent account");
        // Verify splits count is unchanged (rollback occurred)
        let final_split_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(
            initial_split_count, final_split_count,
            "Split count should be unchanged after failed update due to rollback"
        );
        // Verify original transaction is still intact
        if let Some(CmdResult::TaggedEntities(entities)) = GetTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 1, "Expected one transaction");
        } else {
            panic!("Expected transaction to still exist after failed database operation");
        }
    }
    #[local_db_sqlx_test]
    async fn test_update_transaction_prices_and_tags(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        // Create a simple commodity for testing
        let commodity_result = CreateCommodity::new()
            .fraction(1.into())
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        // Create accounts
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        // Create tags for testing manually
        let tag1_id = Uuid::new_v4();
        let tag2_id = Uuid::new_v4();
        let mut conn = user.get_connection().await?;
        sqlx::query!(
            "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
            tag1_id,
            "category",
            "expense",
            Some("Expense category".to_string())
        )
        .execute(&mut *conn)
        .await?;
        sqlx::query!(
            "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
            tag2_id,
            "project",
            "finance_app",
            Some("Finance app project".to_string())
        )
        .execute(&mut *conn)
        .await?;
        // Create initial transaction with simple same-commodity splits
        let tx_id = Uuid::new_v4();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .note("Initial transaction".to_string())
            .run()
            .await?;
        // Test 1: Update transaction with standalone prices (not linked to splits)
        let price1 = Price {
            id: Uuid::new_v4(),
            commodity_id,
            currency_id: commodity_id, // Same commodity for simplicity
            commodity_split: None,     // Not linked to specific splits
            currency_split: None,
            date: now,
            value_num: 100,
            value_denom: 100,
        };
        let prices = vec![FinanceEntity::Price(price1)];
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
            UpdateTransaction::new()
                .user_id(user.id)
                .transaction_id(tx_id)
                .prices(prices)
                .run()
                .await?
        {
            assert_eq!(updated_tx.id, tx_id);
        } else {
            panic!("Expected Transaction entity result for price update");
        }
        // Verify prices were inserted
        let price_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1 AND currency_id = $2",
            commodity_id,
            commodity_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(price_count, 1, "Expected one price record");
        // Test 2: Update transaction with tags
        let mut tags = HashMap::new();
        tags.insert(
            "category".to_string(),
            FinanceEntity::Tag(Tag {
                id: tag1_id,
                tag_name: "category".to_string(),
                tag_value: "expense".to_string(),
                description: Some("Expense category".to_string()),
            }),
        );
        tags.insert(
            "project".to_string(),
            FinanceEntity::Tag(Tag {
                id: tag2_id,
                tag_name: "project".to_string(),
                tag_value: "finance_app".to_string(),
                description: Some("Finance app project".to_string()),
            }),
        );
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
            UpdateTransaction::new()
                .user_id(user.id)
                .transaction_id(tx_id)
                .tags(tags)
                .run()
                .await?
        {
            assert_eq!(updated_tx.id, tx_id);
        } else {
            panic!("Expected Transaction entity result for tag update");
        }
        // Verify tags were inserted
        let tag_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(tag_count, 2, "Expected two tag records");
        // Test 3: Combined update (new splits, new prices, and new tags)
        let new_split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -200,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let new_split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 200,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let new_splits = vec![
            FinanceEntity::Split(new_split1),
            FinanceEntity::Split(new_split2),
        ];
        let new_price = Price {
            id: Uuid::new_v4(),
            commodity_id,
            currency_id: commodity_id,
            commodity_split: None,
            currency_split: None,
            date: now,
            value_num: 110,
            value_denom: 100,
        };
        let new_prices = vec![FinanceEntity::Price(new_price)];
        let mut new_tags = HashMap::new();
        new_tags.insert(
            "category".to_string(),
            FinanceEntity::Tag(Tag {
                id: tag1_id,
                tag_name: "category".to_string(),
                tag_value: "income".to_string(), // Changed value
                description: Some("Expense category".to_string()),
            }),
        );
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
            UpdateTransaction::new()
                .user_id(user.id)
                .transaction_id(tx_id)
                .splits(new_splits)
                .prices(new_prices)
                .tags(new_tags)
                .run()
                .await?
        {
            assert_eq!(updated_tx.id, tx_id);
        } else {
            panic!("Expected Transaction entity result for combined update");
        }
        // Verify all updates were applied atomically
        let final_split_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(final_split_count, 2, "Expected two splits after update");
        let final_price_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1",
            commodity_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(final_price_count, 2, "Expected two prices after update");
        let final_tag_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(final_tag_count, 1, "Expected one tag after update");
        // Test 4: Tag validation failure with non-existent tag
        let mut invalid_tags = HashMap::new();
        invalid_tags.insert(
            "invalid".to_string(),
            FinanceEntity::Tag(Tag {
                id: Uuid::new_v4(), // Non-existent tag
                tag_name: "invalid".to_string(),
                tag_value: "value".to_string(),
                description: Some("Invalid tag".to_string()),
            }),
        );
        let result = UpdateTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .tags(invalid_tags)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for invalid tag with non-existent tag ID"
        );
        // Verify original tags are unchanged after failed tag update
        let unchanged_tag_count = sqlx::query!(
            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(
            unchanged_tag_count, 1,
            "Tag count should be unchanged after failed update"
        );
    }
    #[local_db_sqlx_test]
    async fn test_delete_transaction_simple(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let commodity_result = CreateCommodity::new()
            .fraction(1.into())
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let tx_id = Uuid::new_v4();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .run()
            .await?;
        let result = DeleteTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?;
        assert!(result.is_some(), "Expected successful deletion");
        let mut conn = user.get_connection().await?;
        let tx_exists = sqlx::query!(
            "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(tx_exists, 0, "Transaction should be deleted");
        let splits_exist = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(splits_exist, 0, "Splits should be deleted");
    }
    #[local_db_sqlx_test]
    async fn test_delete_transaction_with_tags_and_prices(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let commodity_result = CreateCommodity::new()
            .fraction(1.into())
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let tx_id = Uuid::new_v4();
        let split1_id = Uuid::new_v4();
        let split2_id = Uuid::new_v4();
        let split1 = Split {
            id: split1_id,
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: split2_id,
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        let price = Price {
            id: Uuid::new_v4(),
            commodity_id,
            currency_id: commodity_id,
            commodity_split: Some(split1_id),
            currency_split: Some(split2_id),
            date: now,
            value_num: 100,
            value_denom: 100,
        };
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .prices(vec![FinanceEntity::Price(price)])
            .note("Test note".to_string())
            .run()
            .await?;
        let mut conn = user.get_connection().await?;
        let tag_count_before = sqlx::query!("SELECT COUNT(*) as count FROM tags")
            .fetch_one(&mut *conn)
            .await?
            .count
            .unwrap_or(0);
        assert!(tag_count_before > 0, "Should have tags before deletion");
        let price_count_before = sqlx::query!(
            "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
            split1_id,
            split2_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(
            price_count_before, 1,
            "Should have one price before deletion"
        );
        let result = DeleteTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?;
        assert!(result.is_some(), "Expected successful deletion");
        let tx_exists = sqlx::query!(
            "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(tx_exists, 0, "Transaction should be deleted");
        let splits_exist = sqlx::query!(
            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(splits_exist, 0, "Splits should be deleted");
        let tx_tags_exist = sqlx::query!(
            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(
            tx_tags_exist, 0,
            "Transaction tags associations should be deleted"
        );
        let prices_exist = sqlx::query!(
            "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
            split1_id,
            split2_id
        )
        .fetch_one(&mut *conn)
        .await?
        .count
        .unwrap_or(0);
        assert_eq!(prices_exist, 0, "Prices should be deleted");
    }
    #[local_db_sqlx_test]
    async fn test_delete_transaction_nonexistent(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let nonexistent_id = Uuid::new_v4();
        let result = DeleteTransaction::new()
            .user_id(user.id)
            .transaction_id(nonexistent_id)
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for non-existent transaction"
        );
        if let Err(CmdError::Args(msg)) = result {
            assert!(msg.contains("Transaction not found"));
        } else {
            panic!("Expected CmdError::Args with 'Transaction not found' message");
        }
    }
    #[local_db_sqlx_test]
    async fn test_delete_transaction_orphaned_tags(pool: PgPool) -> anyhow::Result<()> {
        let user = USER.get().unwrap();
        user.commit()
            .await
            .expect("Failed to commit user to database");
        let commodity_result = CreateCommodity::new()
            .fraction(1.into())
            .symbol("TST".to_string())
            .name("Test Commodity".to_string())
            .user_id(user.id)
            .run()
            .await?;
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
            uuid::Uuid::parse_str(&id)?
        } else {
            panic!("Expected commodity ID string result");
        };
        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 1".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
            CreateAccount::new()
                .name("Account 2".to_string())
                .user_id(user.id)
                .run()
                .await?
        {
            account
        } else {
            panic!("Expected account entity result");
        };
        let tx_id = Uuid::new_v4();
        let split1 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account1.id,
            commodity_id,
            value_num: -100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let split2 = Split {
            id: Uuid::new_v4(),
            tx_id,
            account_id: account2.id,
            commodity_id,
            value_num: 100,
            value_denom: 1,
            reconcile_state: None,
            reconcile_date: None,
            lot_id: None,
        };
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
        let now = Utc::now();
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .note("Orphaned tag test".to_string())
            .run()
            .await?;
        let mut conn = user.get_connection().await?;
        let tag_id = sqlx::query!(
            "SELECT tag_id FROM transaction_tags WHERE tx_id = $1",
            tx_id
        )
        .fetch_one(&mut *conn)
        .await?
        .tag_id;
        DeleteTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?;
        let orphaned_tag_exists =
            sqlx::query!("SELECT COUNT(*) as count FROM tags WHERE id = $1", tag_id)
                .fetch_one(&mut *conn)
                .await?
                .count
                .unwrap_or(0);
        assert_eq!(orphaned_tag_exists, 0, "Orphaned tag should be deleted");
    }
}