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

            
12
pub(super) struct SplitAmountRow {
13
    pub(super) tx_id: Uuid,
14
    pub(super) commodity_id: Uuid,
15
    pub(super) value_num: i64,
16
    pub(super) value_denom: i64,
17
    pub(super) symbol: String,
18
}
19

            
20
/// Aggregate positive-side split values into per-commodity amounts per transaction.
21
///
22
/// Grouping is by commodity **identity**, not symbol — two distinct commodities
23
/// that happen to share a `symbol` tag stay separate. The symbol is carried only
24
/// for display. Returns a map from tx id to a formatted `"amt sym; amt sym"`
25
/// display string, ordered by symbol then commodity id for determinism.
26
351
pub(super) fn aggregate_split_amounts(rows: Vec<SplitAmountRow>) -> HashMap<Uuid, String> {
27
351
    let mut tx_map: HashMap<Uuid, HashMap<Uuid, (String, Rational64)>> = HashMap::new();
28
645
    for row in rows {
29
645
        if row.value_denom == 0 {
30
            continue;
31
645
        }
32
645
        let r = Rational64::new(row.value_num, row.value_denom);
33
645
        let entry = tx_map
34
645
            .entry(row.tx_id)
35
645
            .or_default()
36
645
            .entry(row.commodity_id)
37
645
            .or_insert_with(|| (row.symbol, Rational64::from(0)));
38
645
        entry.1 += r;
39
    }
40
351
    tx_map
41
351
        .into_iter()
42
582
        .map(|(tx_id, commodities)| {
43
582
            let mut pairs: Vec<(Uuid, String, Rational64)> = commodities
44
582
                .into_iter()
45
584
                .map(|(commodity_id, (symbol, amount))| (commodity_id, symbol, amount))
46
582
                .collect();
47
582
            pairs.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
48
582
            let formatted = pairs
49
582
                .into_iter()
50
584
                .map(|(_, symbol, amount)| format!("{} {}", format_split_amount(amount), symbol))
51
582
                .collect::<Vec<_>>()
52
582
                .join("; ");
53
582
            (tx_id, formatted)
54
582
        })
55
351
        .collect()
56
351
}
57

            
58
584
fn format_split_amount(r: Rational64) -> String {
59
584
    if *r.denom() == 1 {
60
583
        r.numer().to_string()
61
    } else {
62
1
        format!("{}/{}", r.numer(), r.denom())
63
    }
64
584
}
65

            
66
/// Aggregate per-transaction amount summaries for the given ids in one query.
67
378
async fn load_split_amounts(
68
378
    conn: &mut sqlx::PgConnection,
69
378
    tx_ids: &[Uuid],
70
378
) -> Result<HashMap<Uuid, String>, sqlx::Error> {
71
378
    if tx_ids.is_empty() {
72
33
        return Ok(HashMap::new());
73
345
    }
74
345
    let rows = sqlx::query_file!("sql/select/splits/for_list.sql", tx_ids)
75
345
        .fetch_all(conn)
76
345
        .await?
77
345
        .into_iter()
78
345
        .map(|r| SplitAmountRow {
79
636
            tx_id: r.tx_id,
80
636
            commodity_id: r.commodity_id,
81
636
            value_num: r.value_num,
82
636
            value_denom: r.value_denom,
83
636
            symbol: r.symbol,
84
636
        })
85
345
        .collect();
86
345
    Ok(aggregate_split_amounts(rows))
87
378
}
88

            
89
use crate::script::TransactionState;
90
use crate::{config::ConfigError, user::User};
91

            
92
use super::{CmdError, CmdResult, FinanceEntity, PaginationInfo};
93

            
94
command! {
95
    CreateTransaction {
96
        #[required]
97
        user_id: Uuid,
98
        #[required]
99
        splits: Vec<FinanceEntity>,
100
        #[required]
101
        id: Uuid,
102
        #[required]
103
        post_date: DateTime<Utc>,
104
        #[required]
105
        enter_date: DateTime<Utc>,
106
        #[optional]
107
        prices: Vec<FinanceEntity>,
108
        #[optional]
109
        note: String,
110
        #[optional]
111
        split_tags: Vec<(Uuid, Tag)>,
112
    } => {
113

            
114

            
115
        let user = User { id: user_id };
116

            
117
75
        let mut conn = user.get_connection().await.map_err(|err| {
118
75
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
119
75
            ConfigError::DB
120
75
        })?;
121

            
122
        let tx = Transaction {
123
            id,
124
            post_date,
125
            enter_date,
126
        };
127

            
128
        let (transaction, splits, prices, transaction_tags, split_tags) = {
129
            let scripts: Vec<(Uuid, Vec<u8>)> = sqlx::query_file!("sql/select/artifacts/enabled.sql")
130
                .fetch_all(&mut *conn)
131
                .await?
132
                .into_iter()
133
4
                .map(|row| (row.id, row.bytecode))
134
                .collect();
135

            
136
            let state = TransactionState::new(tx)
137
                .with(splits)
138
                .with(prices.unwrap_or_default())
139
                .with_note(note)
140
                .with_split_tags(split_tags.unwrap_or_default());
141

            
142
            let state = if scripts.is_empty() {
143
                state
144
            } else {
145
3
                let report = tokio::task::spawn_blocking(move || {
146
3
                    let executor = ScriptExecutor::try_new()?;
147
3
                    state.run_scripts(&executor, &scripts)
148
3
                })
149
                .await
150
                .map_err(|e| CmdError::Script(format!("{e:?}")))?
151
                .map_err(|e| {
152
                    log::error!("{}", t!("Script execution failed: %{err}", err = e : {:?}));
153
                    CmdError::Script(format!("{e:?}"))
154
                })?;
155
                for failure in &report.failures {
156
                    log::error!(
157
                        "{}",
158
                        t!(
159
                            "Script %{id} failed: %{code}: %{message}",
160
                            id = failure.script_id,
161
                            code = failure.code,
162
                            message = failure.message
163
                        )
164
                    );
165
                }
166
                report.state
167
            };
168

            
169
            (state.transaction, state.splits, state.prices, state.transaction_tags, state.split_tags)
170
        };
171

            
172
        // Common ticket operations
173
        let mut ticket = transaction.enter(&mut *conn).await?;
174

            
175
        let split_refs: Vec<_> = splits.iter().collect();
176
        ticket.add_splits(&split_refs).await?;
177

            
178
        // Validate prices before inserting: every linked split a price references
179
        // must belong to THIS transaction's own split set. Mirrors the B1 check on
180
        // UpdateTransaction — the FK alone would accept a foreign/stale split id.
181
        if !prices.is_empty() {
182
            let valid_split_ids: std::collections::HashSet<Uuid> =
183
                splits.iter().map(|split| split.id).collect();
184
            for price in &prices {
185
                for split_id in [price.commodity_split, price.currency_split]
186
                    .into_iter()
187
                    .flatten()
188
                {
189
                    if !valid_split_ids.contains(&split_id) {
190
                        return Err(CmdError::Args(
191
                            "Price references a split that is not part of this transaction"
192
                                .to_string(),
193
                        ));
194
                    }
195
                }
196
            }
197
            let price_refs: Vec<_> = prices.iter().collect();
198
            ticket.add_conversions(&price_refs).await?;
199
        }
200

            
201
        if !transaction_tags.is_empty() {
202
            let tag_refs: Vec<_> = transaction_tags.iter().collect();
203
            ticket.add_tags(&tag_refs).await?;
204
        }
205

            
206
        if !split_tags.is_empty() {
207
            ticket.add_split_tags(&split_tags).await?;
208
        }
209

            
210
        ticket.commit().await?;
211

            
212
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(transaction))))
213
    }
214
11500
}
215

            
216
command! {
217
    ListTransactions {
218
        #[required]
219
        user_id: Uuid,
220
        #[optional]
221
        account: Uuid,
222
        #[optional]
223
        limit: i64,
224
        #[optional]
225
        offset: i64,
226
        #[optional]
227
        date_from: DateTime<Utc>,
228
        #[optional]
229
        date_to: DateTime<Utc>,
230
    } => {
231
        let user = User { id: user_id };
232
275
        let mut conn = user.get_connection().await.map_err(|err| {
233
275
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
234
275
            ConfigError::DB
235
275
        })?;
236

            
237
        let account_uuid = account.as_ref();
238
        let effective_limit = limit.unwrap_or(20);
239
        let effective_offset = offset.unwrap_or(0);
240
        let date_from_ref = date_from.as_ref();
241
        let date_to_ref = date_to.as_ref();
242

            
243
        let count_result = sqlx::query_file!(
244
            "sql/count/transactions/filtered.sql",
245
            account_uuid,
246
            date_from_ref,
247
            date_to_ref
248
        )
249
        .fetch_one(&mut *conn)
250
        .await?;
251

            
252
        let total_count = count_result.count.unwrap_or(0);
253

            
254
        let transactions = sqlx::query_file!(
255
            "sql/select/transactions/paginated.sql",
256
            account_uuid,
257
            date_from_ref,
258
            date_to_ref,
259
            effective_limit,
260
            effective_offset
261
        )
262
        .fetch_all(&mut *conn)
263
        .await?;
264

            
265
        let tx_ids: Vec<Uuid> = transactions.iter().map(|r| r.id).collect();
266
        let split_amounts = load_split_amounts(&mut conn, &tx_ids).await?;
267

            
268
        let mut tagged_transactions = Vec::new();
269
        for tx_row in transactions {
270
            let transaction = Transaction {
271
                id: tx_row.id,
272
                post_date: tx_row.post_date,
273
                enter_date: tx_row.enter_date,
274
            };
275

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

            
294
            let amount = split_amounts.get(&transaction.id).cloned();
295
            tagged_transactions.push((FinanceEntity::Transaction(transaction), tags, amount));
296
        }
297

            
298
        let pagination = PaginationInfo {
299
            total_count,
300
            limit: effective_limit,
301
            offset: effective_offset,
302
            has_more: effective_offset + (tagged_transactions.len() as i64) < total_count,
303
        };
304

            
305
        Ok(Some(CmdResult::TaggedTransactions {
306
            entities: tagged_transactions,
307
            pagination: Some(pagination),
308
        }))
309
    }
310
2492
}
311

            
312
command! {
313
    GetTransaction {
314
        #[required]
315
        user_id: Uuid,
316
        #[required]
317
        transaction_id: Uuid,
318
    } => {
319
        let user = User { id: user_id };
320
        let mut conn = user.get_connection().await.map_err(|err| {
321
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
322
            ConfigError::DB
323
        })?;
324

            
325
        let tx_row = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
326
            .fetch_optional(&mut *conn)
327
            .await?;
328

            
329
        if let Some(tx_row) = tx_row {
330
            let transaction = Transaction {
331
                id: tx_row.id,
332
                post_date: tx_row.post_date,
333
                enter_date: tx_row.enter_date,
334
            };
335

            
336
            let tags: HashMap<String, FinanceEntity> =
337
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
338
                    .fetch_all(&mut *conn)
339
                    .await?
340
                    .into_iter()
341
34
                    .map(|row| {
342
34
                        (
343
34
                            row.tag_name.clone(),
344
34
                            FinanceEntity::Tag(Tag {
345
34
                                id: row.id,
346
34
                                tag_name: row.tag_name,
347
34
                                tag_value: row.tag_value,
348
34
                                description: row.description,
349
34
                            }),
350
34
                        )
351
34
                    })
352
                    .collect();
353

            
354
            let amount = load_split_amounts(&mut conn, &[transaction.id])
355
                .await?
356
                .remove(&transaction.id);
357

            
358
            Ok(Some(CmdResult::TaggedTransactions {
359
                entities: vec![(FinanceEntity::Transaction(transaction), tags, amount)],
360
                pagination: None,
361
            }))
362
        } else {
363
            Ok(None)
364
        }
365
    }
366
380
}
367

            
368
command! {
369
    GetTransactionDetail {
370
        #[required]
371
        user_id: Uuid,
372
        #[required]
373
        transaction_id: Uuid,
374
    } => {
375
        let user = User { id: user_id };
376
        let mut conn = user.get_connection().await.map_err(|err| {
377
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
378
            ConfigError::DB
379
        })?;
380

            
381
        let tx_row = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
382
            .fetch_optional(&mut *conn)
383
            .await?;
384

            
385
        if let Some(tx_row) = tx_row {
386
            let transaction = Transaction {
387
                id: tx_row.id,
388
                post_date: tx_row.post_date,
389
                enter_date: tx_row.enter_date,
390
            };
391

            
392
            let tags: HashMap<String, FinanceEntity> =
393
                sqlx::query_file!("sql/select/tags/by_transaction.sql", &transaction.id)
394
                    .fetch_all(&mut *conn)
395
                    .await?
396
                    .into_iter()
397
1
                    .map(|row| {
398
1
                        (
399
1
                            row.tag_name.clone(),
400
1
                            FinanceEntity::Tag(Tag {
401
1
                                id: row.id,
402
1
                                tag_name: row.tag_name,
403
1
                                tag_value: row.tag_value,
404
1
                                description: row.description,
405
1
                            }),
406
1
                        )
407
1
                    })
408
                    .collect();
409

            
410
            let split_entities: Vec<(FinanceEntity, HashMap<String, FinanceEntity>)> =
411
                sqlx::query_file!("sql/select/splits/by_transaction.sql", transaction_id)
412
                    .fetch_all(&mut *conn)
413
                    .await?
414
                    .into_iter()
415
4
                    .map(|row| {
416
4
                        (
417
4
                            FinanceEntity::Split(finance::split::Split {
418
4
                                id: row.id,
419
4
                                tx_id: row.tx_id,
420
4
                                account_id: row.account_id,
421
4
                                commodity_id: row.commodity_id,
422
4
                                value_num: row.value_num,
423
4
                                value_denom: row.value_denom,
424
4
                                reconcile_state: row.reconcile_state,
425
4
                                reconcile_date: row.reconcile_date,
426
4
                                lot_id: row.lot_id,
427
4
                            }),
428
4
                            HashMap::new(),
429
4
                        )
430
4
                    })
431
                    .collect();
432

            
433
            let price_entities: Vec<(FinanceEntity, HashMap<String, FinanceEntity>)> =
434
                sqlx::query_file!("sql/select/prices/by_transaction.sql", transaction_id)
435
                    .fetch_all(&mut *conn)
436
                    .await?
437
                    .into_iter()
438
1
                    .map(|row| {
439
1
                        (
440
1
                            FinanceEntity::Price(finance::price::Price {
441
1
                                id: row.id,
442
1
                                date: row.price_date,
443
1
                                commodity_id: row.commodity_id,
444
1
                                currency_id: row.currency_id,
445
1
                                commodity_split: row.commodity_split_id,
446
1
                                currency_split: row.currency_split_id,
447
1
                                value_num: row.value_num,
448
1
                                value_denom: row.value_denom,
449
1
                            }),
450
1
                            HashMap::new(),
451
1
                        )
452
1
                    })
453
                    .collect();
454

            
455
            let mut entities: Vec<(FinanceEntity, HashMap<String, FinanceEntity>)> =
456
                vec![(FinanceEntity::Transaction(transaction), tags)];
457
            entities.extend(split_entities);
458
            entities.extend(price_entities);
459

            
460
            Ok(Some(CmdResult::TaggedEntities {
461
                entities,
462
                pagination: None,
463
            }))
464
        } else {
465
            Ok(None)
466
        }
467
    }
468
273
}
469

            
470
command! {
471
    UpdateTransaction {
472
        #[required]
473
        user_id: Uuid,
474
        #[required]
475
        transaction_id: Uuid,
476
        #[optional]
477
        splits: Vec<FinanceEntity>,
478
        #[optional]
479
        post_date: DateTime<Utc>,
480
        #[optional]
481
        enter_date: DateTime<Utc>,
482
        #[optional]
483
        note: String,
484
        #[optional]
485
        prices: Vec<FinanceEntity>,
486
        #[optional]
487
        tags: HashMap<String, FinanceEntity>,
488
    } => {
489
        let user = User { id: user_id };
490
        let mut conn = user.get_connection().await.map_err(|err| {
491
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
492
            ConfigError::DB
493
        })?;
494

            
495
        let mut tx = conn.begin().await?;
496

            
497
        let existing = sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
498
            .fetch_optional(&mut *tx)
499
            .await?
500
26
            .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
501

            
502
        let final_post_date = post_date.unwrap_or(existing.post_date);
503
        let final_enter_date = enter_date.unwrap_or(existing.enter_date);
504

            
505
        // The split ids that will belong to this transaction after the update.
506
        // Captured here so prices can be checked against the replacement set
507
        // before splits is consumed by the delete/insert below.
508
        let mut new_split_ids: Option<std::collections::HashSet<Uuid>> = None;
509

            
510
        // Validate new splits if provided (before making any changes)
511
        if let Some(ref new_splits) = splits {
512
            let mut commodity_sums: std::collections::HashMap<Uuid, num_rational::Rational64> =
513
                std::collections::HashMap::new();
514
            let mut ids = std::collections::HashSet::new();
515

            
516
            for entity in new_splits {
517
                if let FinanceEntity::Split(split) = entity {
518
                    // Validate split belongs to this transaction
519
                    if split.tx_id != transaction_id {
520
                        return Err(CmdError::Args("Split transaction ID mismatch".to_string()));
521
                    }
522
                    ids.insert(split.id);
523

            
524
                    let split_value =
525
                        num_rational::Rational64::new(split.value_num, split.value_denom);
526
                    *commodity_sums.entry(split.commodity_id).or_insert(
527
                        num_rational::Rational64::new(0, 1),
528
                    ) += split_value;
529
                } else {
530
                    return Err(CmdError::Args("Invalid entity type in splits".to_string()));
531
                }
532
            }
533

            
534
            // Ensure splits sum to zero (double-entry bookkeeping requirement)
535
            // For multi-currency transactions, individual currencies won't sum to zero
536
            // (they're balanced via the price table), so only validate single-currency
537
            if commodity_sums.len() == 1 {
538
                for sum in commodity_sums.values() {
539
                    if *sum != num_rational::Rational64::new(0, 1) {
540
                        return Err(CmdError::Args("Splits must sum to zero".to_string()));
541
                    }
542
                }
543
            }
544

            
545
            new_split_ids = Some(ids);
546
        }
547

            
548
        // Update transaction metadata first
549
        sqlx::query_file!(
550
            "sql/update/transactions/update.sql",
551
            transaction_id,
552
            final_post_date,
553
            final_enter_date
554
        )
555
        .execute(&mut *tx)
556
        .await?;
557

            
558
        // Process splits update atomically: delete then insert
559
        if let Some(new_splits) = splits {
560
            // Delete existing split_tags, splits and their associated prices
561
            sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
562
                .execute(&mut *tx)
563
                .await?;
564

            
565
            sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
566
                .execute(&mut *tx)
567
                .await?;
568

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

            
573
            // Insert new splits
574
            for entity in new_splits {
575
                if let FinanceEntity::Split(split) = entity {
576
                    sqlx::query_file!(
577
                        "sql/insert/splits/split.sql",
578
                        split.id,
579
                        split.tx_id,
580
                        split.account_id,
581
                        split.commodity_id,
582
                        split.reconcile_state,
583
                        split.reconcile_date,
584
                        split.value_num,
585
                        split.value_denom,
586
                        split.lot_id
587
                    )
588
                    .execute(&mut *tx)
589
                    .await?;
590
                }
591
            }
592
        }
593

            
594
        // Validate prices if provided: every price must link two splits that are
595
        // part of this transaction's (post-update) split set. Without this a crafted
596
        // price could reference a foreign/stale split id that the FK alone accepts.
597
        if let Some(ref new_prices) = prices {
598
            let valid_split_ids: std::collections::HashSet<Uuid> = match &new_split_ids {
599
                Some(ids) => ids.clone(),
600
                None => sqlx::query_file!("sql/select/splits/by_transaction.sql", transaction_id)
601
                    .fetch_all(&mut *tx)
602
                    .await?
603
                    .into_iter()
604
                    .map(|row| row.id)
605
                    .collect(),
606
            };
607
            for entity in new_prices {
608
                let FinanceEntity::Price(price) = entity else {
609
                    return Err(CmdError::Args("Invalid entity type in prices".to_string()));
610
                };
611
                // A linked split ref (Some) must belong to this transaction; an
612
                // unlinked standalone price (None) carries no split to validate.
613
                for split_id in [price.commodity_split, price.currency_split].into_iter().flatten() {
614
                    if !valid_split_ids.contains(&split_id) {
615
                        return Err(CmdError::Args(
616
                            "Price references a split that is not part of this transaction"
617
                                .to_string(),
618
                        ));
619
                    }
620
                }
621
            }
622
        }
623

            
624
        // Validate tags if provided
625
        if let Some(ref new_tags) = tags {
626
            for entity in new_tags.values() {
627
                if let FinanceEntity::Tag(_) = entity {
628
                    // Tag validation could be added here
629
                } else {
630
                    return Err(CmdError::Args("Invalid entity type in tags".to_string()));
631
                }
632
            }
633
        }
634

            
635
        // Process prices update atomically
636
        if let Some(new_prices) = prices {
637
            for entity in new_prices {
638
                if let FinanceEntity::Price(price) = entity {
639
                    sqlx::query_file!(
640
                        "sql/insert/prices/price.sql",
641
                        price.id,
642
                        price.commodity_id,
643
                        price.currency_id,
644
                        price.commodity_split,
645
                        price.currency_split,
646
                        price.date,
647
                        price.value_num,
648
                        price.value_denom
649
                    )
650
                    .execute(&mut *tx)
651
                    .await?;
652
                }
653
            }
654
        }
655

            
656
        // Process tags update atomically: delete then insert
657
        if let Some(new_tags) = tags {
658
            sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
659
                .execute(&mut *tx)
660
                .await?;
661

            
662
            for (_, entity) in new_tags {
663
                if let FinanceEntity::Tag(tag) = entity {
664
                    sqlx::query_file!(
665
                        "sql/insert/transaction_tags/transaction_tag.sql",
666
                        transaction_id,
667
                        tag.id
668
                    )
669
                    .execute(&mut *tx)
670
                    .await?;
671
                }
672
            }
673
        }
674

            
675
        // Handle note field by creating/updating note tag
676
        if let Some(note_value) = note {
677
            // First delete any existing note tag for this transaction
678
            sqlx::query!("DELETE FROM transaction_tags WHERE tx_id = $1 AND tag_id IN (SELECT id FROM tags WHERE tag_name = 'note')", transaction_id)
679
                .execute(&mut *tx)
680
                .await?;
681

            
682
            if !note_value.trim().is_empty() {
683
                let note_tag_id = Tag {
684
                    id: Uuid::new_v4(),
685
                    tag_name: "note".to_string(),
686
                    tag_value: note_value,
687
                    description: None,
688
                }
689
                .commit(&mut *tx)
690
                .await?;
691

            
692
                sqlx::query_file!(
693
                    "sql/insert/transaction_tags/transaction_tag.sql",
694
                    transaction_id,
695
                    note_tag_id
696
                )
697
                .execute(&mut *tx)
698
                .await?;
699
            }
700
        }
701

            
702
        tx.commit().await?;
703

            
704
        let updated_transaction = Transaction {
705
            id: transaction_id,
706
            post_date: final_post_date,
707
            enter_date: final_enter_date,
708
        };
709

            
710
        Ok(Some(CmdResult::Entity(FinanceEntity::Transaction(updated_transaction))))
711
    }
712
1203
}
713

            
714
command! {
715
    DeleteTransaction {
716
        #[required]
717
        user_id: Uuid,
718
        #[required]
719
        transaction_id: Uuid,
720
    } => {
721
        let user = User { id: user_id };
722
        let mut conn = user.get_connection().await.map_err(|err| {
723
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
724
            ConfigError::DB
725
        })?;
726

            
727
        sqlx::query_file!("sql/select/transactions/by_id.sql", transaction_id)
728
            .fetch_optional(&mut *conn)
729
            .await?
730
26
            .ok_or_else(|| CmdError::Args("Transaction not found".to_string()))?;
731

            
732
        let mut tx = conn.begin().await?;
733

            
734
        let tag_ids_to_check: Vec<Uuid> = sqlx::query_file!(
735
            "sql/select/tags/by_transaction_and_splits.sql",
736
            transaction_id
737
        )
738
        .fetch_all(&mut *tx)
739
        .await?
740
        .into_iter()
741
        .filter_map(|row| row.tag_id)
742
        .collect();
743

            
744
        sqlx::query_file!("sql/delete/prices/by_splits.sql", transaction_id)
745
            .execute(&mut *tx)
746
            .await?;
747

            
748
        sqlx::query_file!("sql/delete/split_tags/by_transaction.sql", transaction_id)
749
            .execute(&mut *tx)
750
            .await?;
751

            
752
        sqlx::query_file!("sql/delete/transaction_tags/by_transaction.sql", transaction_id)
753
            .execute(&mut *tx)
754
            .await?;
755

            
756
        for tag_id in tag_ids_to_check {
757
            let is_orphaned = sqlx::query_file!("sql/check/tags/is_orphaned.sql", tag_id)
758
                .fetch_one(&mut *tx)
759
                .await?
760
                .is_orphaned
761
                .unwrap_or(false);
762

            
763
            if is_orphaned {
764
                sqlx::query_file!("sql/delete/tags/by_id.sql", tag_id)
765
                    .execute(&mut *tx)
766
                    .await?;
767
            }
768
        }
769

            
770
        sqlx::query_file!("sql/delete/splits/by_transaction.sql", transaction_id)
771
            .execute(&mut *tx)
772
            .await?;
773

            
774
        sqlx::query_file!("sql/delete/transactions/by_id.sql", transaction_id)
775
            .execute(&mut *tx)
776
            .await?;
777

            
778
        tx.commit().await?;
779

            
780
        Ok(Some(CmdResult::String("Transaction deleted successfully".to_string())))
781
    }
782
246
}
783

            
784
// Idempotent set: replace any existing (tx, tag_name) link with the
785
// supplied `tag_value`. Mirrors `SetSplitTag` and `SetAccountTag` so
786
// the script-side `set-transaction-tag` native reads symmetrically.
787
command! {
788
    SetTransactionTag {
789
        #[required]
790
        user_id: Uuid,
791
        #[required]
792
        transaction_id: Uuid,
793
        #[required]
794
        tag_name: String,
795
        #[required]
796
        tag_value: String,
797
        #[optional]
798
        description: String,
799
    } => {
800
        let user = User { id: user_id };
801
        let desc = description.and_then(|text| {
802
            if text.trim().is_empty() {
803
                None
804
            } else {
805
                Some(text)
806
            }
807
        });
808
        let tag = Tag {
809
            id: Uuid::new_v4(),
810
            tag_name,
811
            tag_value,
812
            description: desc,
813
        };
814
        user.set_transaction_tag(transaction_id, &tag)
815
            .await
816
            .map_err(|err| {
817
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
818
                CmdError::Args(format!("{err:?}"))
819
            })?;
820
        Ok(Some(CmdResult::String("ok".to_string())))
821
    }
822
175
}
823

            
824
// Looks up a single tag value by name on a transaction. Returns
825
// `CmdResult::String("")` when absent (matches `GetSplitTag`).
826
command! {
827
    GetTransactionTag {
828
        #[required]
829
        user_id: Uuid,
830
        #[required]
831
        transaction_id: Uuid,
832
        #[required]
833
        tag_name: String,
834
    } => {
835
        let user = User { id: user_id };
836
        let tags = user
837
            .get_transaction_tags(transaction_id)
838
            .await
839
            .map_err(|err| {
840
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
841
                CmdError::Args(format!("{err:?}"))
842
            })?;
843
        let value = tags
844
            .into_iter()
845
            .find(|t| t.tag_name == tag_name)
846
            .map(|t| t.tag_value)
847
            .unwrap_or_default();
848
        Ok(Some(CmdResult::String(value)))
849
    }
850
}
851

            
852
#[cfg(test)]
853
mod command_tests {
854
    use super::*;
855
    use crate::{
856
        command::{account::CreateAccount, commodity::CreateCommodity},
857
        db::DB_POOL,
858
    };
859
    use chrono::Duration;
860
    use finance::{account::Account, price::Price, split::Split};
861
    use sqlx::PgPool;
862
    use supp_macro::local_db_sqlx_test;
863
    use tokio::sync::OnceCell;
864

            
865
    /// Context for keeping environment intact
866
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
867
    static USER: OnceCell<User> = OnceCell::const_new();
868

            
869
18
    async fn setup() {
870
18
        CONTEXT
871
18
            .get_or_init(|| async {
872
                #[cfg(feature = "testlog")]
873
1
                let _ = env_logger::builder()
874
1
                    .is_test(true)
875
1
                    .filter_level(log::LevelFilter::Trace)
876
1
                    .try_init();
877
2
            })
878
18
            .await;
879
18
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
880
18
            .await;
881
18
    }
882

            
883
    #[local_db_sqlx_test]
884
    async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
885
        let user = USER.get().unwrap();
886
        user.commit()
887
            .await
888
            .expect("Failed to commit user to database");
889

            
890
        // First create a commodity
891
        let commodity_result = CreateCommodity::new()
892
            .symbol("TST".to_string())
893
            .name("Test Commodity".to_string())
894
            .user_id(user.id)
895
            .run()
896
            .await?;
897

            
898
        // Get the commodity ID
899
        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
900
            uuid::Uuid::parse_str(&id)?
901
        } else {
902
            panic!("Expected commodity ID string result");
903
        };
904

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

            
918
        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
919
            CreateAccount::new()
920
                .name("Account 2".to_string())
921
                .user_id(user.id)
922
                .run()
923
                .await?
924
        {
925
            account
926
        } else {
927
            panic!("Expected account entity result");
928
        };
929

            
930
        let tx_id = Uuid::new_v4();
931

            
932
        // Create splits
933
        let split1 = Split::builder()
934
            .id(Uuid::new_v4())
935
            .tx_id(tx_id)
936
            .account_id(account1.id)
937
            .commodity_id(commodity_id)
938
            .value_num(100)
939
            .value_denom(1)
940
            .build()?;
941

            
942
        let split2 = Split::builder()
943
            .id(Uuid::new_v4())
944
            .tx_id(tx_id)
945
            .account_id(account2.id)
946
            .commodity_id(commodity_id)
947
            .value_num(-100)
948
            .value_denom(1)
949
            .build()?;
950

            
951
        // Create transaction with splits
952
        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
953
        let now = Utc::now();
954

            
955
        if let Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) = CreateTransaction::new()
956
            .user_id(user.id)
957
            .splits(splits)
958
            .id(tx_id)
959
            .post_date(now)
960
            .enter_date(now)
961
            .run()
962
            .await?
963
        {
964
            assert!(!tx.id.is_nil());
965

            
966
            // Verify splits were created
967
            let mut conn = user.get_connection().await?;
968
            let splits = sqlx::query_file!("sql/count/splits/by_transaction.sql", tx.id)
969
                .fetch_one(&mut *conn)
970
                .await?;
971
            assert_eq!(splits.count, Some(2));
972
        } else {
973
            panic!("Expected transaction entity result");
974
        }
975
    }
976
    #[local_db_sqlx_test]
977
    async fn test_list_transactions_empty(pool: PgPool) -> anyhow::Result<()> {
978
        let user = USER.get().unwrap();
979
        user.commit()
980
            .await
981
            .expect("Failed to commit user to database");
982

            
983
        if let Some(CmdResult::TaggedTransactions {
984
            entities,
985
            pagination: Some(pagination),
986
        }) = ListTransactions::new().user_id(user.id).run().await?
987
        {
988
            assert!(
989
                entities.is_empty(),
990
                "Expected no transactions in empty database"
991
            );
992
            assert_eq!(pagination.total_count, 0);
993
            assert_eq!(pagination.limit, 20);
994
            assert_eq!(pagination.offset, 0);
995
            assert!(!pagination.has_more);
996
        } else {
997
            panic!("Expected TaggedTransactions result with pagination");
998
        }
999
    }
    #[local_db_sqlx_test]
    async fn test_list_transactions_with_data(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?;
        // Get the commodity ID
        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 between the accounts
        let tx_id = Uuid::new_v4();
        let now = Utc::now();
        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)];
        CreateTransaction::new()
            .user_id(user.id)
            .splits(splits)
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .run()
            .await?;
        // List all transactions
        if let Some(CmdResult::TaggedTransactions {
            entities,
            pagination: Some(pagination),
        }) = ListTransactions::new().user_id(user.id).run().await?
        {
            assert_eq!(entities.len(), 1, "Expected one transaction");
            assert_eq!(pagination.total_count, 1);
            let (entity, _tags, _amount) = &entities[0];
            if let FinanceEntity::Transaction(tx) = entity {
                assert_eq!(tx.id, tx_id);
            } else {
                panic!("Expected Transaction entity");
            }
        } else {
            panic!("Expected TaggedTransactions result with pagination");
        }
        // List transactions filtered by account
        if let Some(CmdResult::TaggedTransactions { entities, .. }) = ListTransactions::new()
            .user_id(user.id)
            .account(account1.id)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 1, "Expected one transaction for account1");
        } else {
            panic!("Expected TaggedTransactions result");
        }
        // List transactions for non-existent account
        if let Some(CmdResult::TaggedTransactions { entities, .. }) = ListTransactions::new()
            .user_id(user.id)
            .account(Uuid::new_v4())
            .run()
            .await?
        {
            assert_eq!(
                entities.len(),
                0,
                "Expected no transactions for non-existent account"
            );
        } else {
            panic!("Expected TaggedTransactions result");
        }
    }
    #[local_db_sqlx_test]
    async fn test_get_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("Test transaction".to_string())
            .run()
            .await?;
        // Test GetTransaction
        if let Some(CmdResult::TaggedTransactions { entities, .. }) = GetTransaction::new()
            .user_id(user.id)
            .transaction_id(tx_id)
            .run()
            .await?
        {
            assert_eq!(entities.len(), 1, "Expected one transaction");
            let (entity, _tags, _amount) = &entities[0];
            if let FinanceEntity::Transaction(tx) = entity {
                assert_eq!(tx.id, tx_id);
            } else {
                panic!("Expected Transaction entity");
            }
        } else {
            panic!("Expected TaggedTransactions 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::TaggedTransactions { 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::TaggedTransactions { 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::TaggedTransactions { 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();
        // A liveness guard, not a performance budget: it exists so a script that
        // deadlocks fails the suite instead of hanging it. The bound has to hold
        // on the slowest machine that runs this, which is a 4-vCPU CI node under
        // -Cinstrument-coverage executing two wasm scripts — 10s failed there
        // while passing everywhere else, which is a flaky test, not a finding.
        let result = tokio::time::timeout(
            std::time::Duration::from_secs(120),
            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 hung (>120s) — a script or lock is stuck"
        );
        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::TaggedTransactions {
            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 TaggedTransactions result with pagination");
        }
        // Test limit=5 returns exactly 5 transactions
        if let Some(CmdResult::TaggedTransactions {
            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 TaggedTransactions result with pagination");
        }
        // Test limit=100 returns all 25 transactions (limit > total)
        if let Some(CmdResult::TaggedTransactions {
            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 TaggedTransactions 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::TaggedTransactions {
            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 TaggedTransactions result with pagination");
        }
        // Test offset=20, limit=10 returns last page (only 5 remaining)
        if let Some(CmdResult::TaggedTransactions {
            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 TaggedTransactions result with pagination");
        }
        // Test offset beyond total returns empty
        if let Some(CmdResult::TaggedTransactions {
            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 TaggedTransactions 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::TaggedTransactions {
            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 TaggedTransactions result with pagination");
        }
        // Test date_to filter (older than 20 days)
        let date_to = base_time - Duration::days(20);
        if let Some(CmdResult::TaggedTransactions {
            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 TaggedTransactions 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::TaggedTransactions {
            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 TaggedTransactions result with pagination");
        }
        // Test date range with pagination
        let date_from = base_time - Duration::days(29);
        if let Some(CmdResult::TaggedTransactions {
            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 TaggedTransactions result with pagination");
        }
    }
    #[local_db_sqlx_test]
    async fn test_create_transaction_rejects_foreign_split_price(
        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 now = Utc::now();
        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,
        };
        // Price whose currency_split points at a split id NOT in this transaction.
        let foreign_split_id = Uuid::new_v4();
        let price = Price {
            id: Uuid::new_v4(),
            commodity_id,
            currency_id: commodity_id,
            commodity_split: Some(split1_id),
            currency_split: Some(foreign_split_id),
            date: now,
            value_num: 100,
            value_denom: 100,
        };
        let result = CreateTransaction::new()
            .user_id(user.id)
            .splits(vec![
                FinanceEntity::Split(split1),
                FinanceEntity::Split(split2),
            ])
            .id(tx_id)
            .post_date(now)
            .enter_date(now)
            .prices(vec![FinanceEntity::Price(price)])
            .run()
            .await;
        assert!(
            result.is_err(),
            "Expected error for price referencing a foreign split"
        );
        let mut conn = user.get_connection().await?;
        let tx_count = 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_count, 0,
            "No transaction must persist on validation error"
        );
        let 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!(split_count, 0, "No splits must persist on validation error");
    }
}
#[cfg(test)]
mod aggregate_tests {
    use super::{SplitAmountRow, aggregate_split_amounts};
    use sqlx::types::Uuid;
    /// Deterministic commodity id per symbol so same-symbol rows aggregate.
7
    fn commodity_for(symbol: &str) -> Uuid {
21
        let n = symbol.bytes().fold(0u128, |acc, b| {
21
            acc.wrapping_mul(31).wrapping_add(u128::from(b))
21
        });
7
        Uuid::from_u128(n | 1)
7
    }
7
    fn row(tx_id: Uuid, num: i64, denom: i64, symbol: &str) -> SplitAmountRow {
7
        row_cid(tx_id, commodity_for(symbol), num, denom, symbol)
7
    }
9
    fn row_cid(
9
        tx_id: Uuid,
9
        commodity_id: Uuid,
9
        num: i64,
9
        denom: i64,
9
        symbol: &str,
9
    ) -> SplitAmountRow {
9
        SplitAmountRow {
9
            tx_id,
9
            commodity_id,
9
            value_num: num,
9
            value_denom: denom,
9
            symbol: symbol.to_string(),
9
        }
9
    }
    #[test]
1
    fn same_symbol_distinct_commodities_stay_separate() {
1
        let tx = Uuid::new_v4();
1
        let result = aggregate_split_amounts(vec![
1
            row_cid(tx, Uuid::from_u128(1), 100, 1, "USD"),
1
            row_cid(tx, Uuid::from_u128(2), 50, 1, "USD"),
        ]);
1
        assert_eq!(result.get(&tx).map(String::as_str), Some("100 USD; 50 USD"));
1
    }
    #[test]
1
    fn empty_input_returns_empty_map() {
1
        assert!(aggregate_split_amounts(vec![]).is_empty());
1
    }
    #[test]
1
    fn single_commodity_sums_correctly() {
1
        let id = Uuid::new_v4();
1
        let result = aggregate_split_amounts(vec![row(id, 50, 1, "USD"), row(id, 50, 1, "USD")]);
1
        assert_eq!(result.get(&id).map(String::as_str), Some("100 USD"));
1
    }
    #[test]
1
    fn multi_commodity_formats_sorted() {
1
        let id = Uuid::new_v4();
1
        let result = aggregate_split_amounts(vec![row(id, 100, 1, "USD"), row(id, 50, 1, "EUR")]);
1
        assert_eq!(result.get(&id).map(String::as_str), Some("50 EUR; 100 USD"));
1
    }
    #[test]
1
    fn fractional_amount_formatted() {
1
        let id = Uuid::new_v4();
1
        let result = aggregate_split_amounts(vec![row(id, 1, 3, "BTC")]);
1
        assert_eq!(result.get(&id).map(String::as_str), Some("1/3 BTC"));
1
    }
    #[test]
1
    fn multiple_transactions_independent() {
1
        let id1 = Uuid::new_v4();
1
        let id2 = Uuid::new_v4();
1
        let result =
1
            aggregate_split_amounts(vec![row(id1, 100, 1, "USD"), row(id2, 200, 1, "EUR")]);
1
        assert_eq!(result.get(&id1).map(String::as_str), Some("100 USD"));
1
        assert_eq!(result.get(&id2).map(String::as_str), Some("200 EUR"));
1
    }
}