Skip to main content

server/command/
transaction.rs

1use finance::{tag::Tag, transaction::Transaction};
2use num_rational::Rational64;
3use scripting::ScriptExecutor;
4use sqlx::{
5    Acquire,
6    types::Uuid,
7    types::chrono::{DateTime, Utc},
8};
9use std::{collections::HashMap, fmt::Debug};
10use supp_macro::command;
11
12pub(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.
26pub(super) fn aggregate_split_amounts(rows: Vec<SplitAmountRow>) -> HashMap<Uuid, String> {
27    let mut tx_map: HashMap<Uuid, HashMap<Uuid, (String, Rational64)>> = HashMap::new();
28    for row in rows {
29        if row.value_denom == 0 {
30            continue;
31        }
32        let r = Rational64::new(row.value_num, row.value_denom);
33        let entry = tx_map
34            .entry(row.tx_id)
35            .or_default()
36            .entry(row.commodity_id)
37            .or_insert_with(|| (row.symbol, Rational64::from(0)));
38        entry.1 += r;
39    }
40    tx_map
41        .into_iter()
42        .map(|(tx_id, commodities)| {
43            let mut pairs: Vec<(Uuid, String, Rational64)> = commodities
44                .into_iter()
45                .map(|(commodity_id, (symbol, amount))| (commodity_id, symbol, amount))
46                .collect();
47            pairs.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
48            let formatted = pairs
49                .into_iter()
50                .map(|(_, symbol, amount)| format!("{} {}", format_split_amount(amount), symbol))
51                .collect::<Vec<_>>()
52                .join("; ");
53            (tx_id, formatted)
54        })
55        .collect()
56}
57
58fn format_split_amount(r: Rational64) -> String {
59    if *r.denom() == 1 {
60        r.numer().to_string()
61    } else {
62        format!("{}/{}", r.numer(), r.denom())
63    }
64}
65
66/// Aggregate per-transaction amount summaries for the given ids in one query.
67async fn load_split_amounts(
68    conn: &mut sqlx::PgConnection,
69    tx_ids: &[Uuid],
70) -> Result<HashMap<Uuid, String>, sqlx::Error> {
71    if tx_ids.is_empty() {
72        return Ok(HashMap::new());
73    }
74    let rows = sqlx::query_file!("sql/select/splits/for_list.sql", tx_ids)
75        .fetch_all(conn)
76        .await?
77        .into_iter()
78        .map(|r| SplitAmountRow {
79            tx_id: r.tx_id,
80            commodity_id: r.commodity_id,
81            value_num: r.value_num,
82            value_denom: r.value_denom,
83            symbol: r.symbol,
84        })
85        .collect();
86    Ok(aggregate_split_amounts(rows))
87}
88
89use crate::script::TransactionState;
90use crate::{config::ConfigError, user::User};
91
92use super::{CmdError, CmdResult, FinanceEntity, PaginationInfo};
93
94command! {
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        let mut conn = user.get_connection().await.map_err(|err| {
118            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
119            ConfigError::DB
120        })?;
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                .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                let report = tokio::task::spawn_blocking(move || {
146                    let executor = ScriptExecutor::try_new()?;
147                    state.run_scripts(&executor, &scripts)
148                })
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}
215
216command! {
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        let mut conn = user.get_connection().await.map_err(|err| {
233            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
234            ConfigError::DB
235        })?;
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                    .map(|row| {
282                        (
283                            row.tag_name.clone(),
284                            FinanceEntity::Tag(Tag {
285                                id: row.id,
286                                tag_name: row.tag_name,
287                                tag_value: row.tag_value,
288                                description: row.description,
289                            }),
290                        )
291                    })
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}
311
312command! {
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                    .map(|row| {
342                        (
343                            row.tag_name.clone(),
344                            FinanceEntity::Tag(Tag {
345                                id: row.id,
346                                tag_name: row.tag_name,
347                                tag_value: row.tag_value,
348                                description: row.description,
349                            }),
350                        )
351                    })
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}
367
368command! {
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                    .map(|row| {
398                        (
399                            row.tag_name.clone(),
400                            FinanceEntity::Tag(Tag {
401                                id: row.id,
402                                tag_name: row.tag_name,
403                                tag_value: row.tag_value,
404                                description: row.description,
405                            }),
406                        )
407                    })
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                    .map(|row| {
416                        (
417                            FinanceEntity::Split(finance::split::Split {
418                                id: row.id,
419                                tx_id: row.tx_id,
420                                account_id: row.account_id,
421                                commodity_id: row.commodity_id,
422                                value_num: row.value_num,
423                                value_denom: row.value_denom,
424                                reconcile_state: row.reconcile_state,
425                                reconcile_date: row.reconcile_date,
426                                lot_id: row.lot_id,
427                            }),
428                            HashMap::new(),
429                        )
430                    })
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                    .map(|row| {
439                        (
440                            FinanceEntity::Price(finance::price::Price {
441                                id: row.id,
442                                date: row.price_date,
443                                commodity_id: row.commodity_id,
444                                currency_id: row.currency_id,
445                                commodity_split: row.commodity_split_id,
446                                currency_split: row.currency_split_id,
447                                value_num: row.value_num,
448                                value_denom: row.value_denom,
449                            }),
450                            HashMap::new(),
451                        )
452                    })
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}
469
470command! {
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            .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}
713
714command! {
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            .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}
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.
787command! {
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}
823
824// Looks up a single tag value by name on a transaction. Returns
825// `CmdResult::String("")` when absent (matches `GetSplitTag`).
826command! {
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)]
853mod 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    async fn setup() {
870        CONTEXT
871            .get_or_init(|| async {
872                #[cfg(feature = "testlog")]
873                let _ = env_logger::builder()
874                    .is_test(true)
875                    .filter_level(log::LevelFilter::Trace)
876                    .try_init();
877            })
878            .await;
879        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
880            .await;
881    }
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    }
1000
1001    #[local_db_sqlx_test]
1002    async fn test_list_transactions_with_data(pool: PgPool) -> anyhow::Result<()> {
1003        let user = USER.get().unwrap();
1004        user.commit()
1005            .await
1006            .expect("Failed to commit user to database");
1007
1008        // First create a commodity
1009        let commodity_result = CreateCommodity::new()
1010            .symbol("TST".to_string())
1011            .name("Test Commodity".to_string())
1012            .user_id(user.id)
1013            .run()
1014            .await?;
1015
1016        // Get the commodity ID
1017        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1018            uuid::Uuid::parse_str(&id)?
1019        } else {
1020            panic!("Expected commodity ID string result");
1021        };
1022
1023        // Create two accounts
1024        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1025            CreateAccount::new()
1026                .name("Account 1".to_string())
1027                .user_id(user.id)
1028                .run()
1029                .await?
1030        {
1031            account
1032        } else {
1033            panic!("Expected account entity result");
1034        };
1035
1036        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1037            CreateAccount::new()
1038                .name("Account 2".to_string())
1039                .user_id(user.id)
1040                .run()
1041                .await?
1042        {
1043            account
1044        } else {
1045            panic!("Expected account entity result");
1046        };
1047
1048        // Create a transaction between the accounts
1049        let tx_id = Uuid::new_v4();
1050        let now = Utc::now();
1051
1052        let split1 = Split {
1053            id: Uuid::new_v4(),
1054            tx_id,
1055            account_id: account1.id,
1056            commodity_id,
1057            value_num: -100,
1058            value_denom: 1,
1059            reconcile_state: None,
1060            reconcile_date: None,
1061            lot_id: None,
1062        };
1063
1064        let split2 = Split {
1065            id: Uuid::new_v4(),
1066            tx_id,
1067            account_id: account2.id,
1068            commodity_id,
1069            value_num: 100,
1070            value_denom: 1,
1071            reconcile_state: None,
1072            reconcile_date: None,
1073            lot_id: None,
1074        };
1075
1076        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1077        CreateTransaction::new()
1078            .user_id(user.id)
1079            .splits(splits)
1080            .id(tx_id)
1081            .post_date(now)
1082            .enter_date(now)
1083            .run()
1084            .await?;
1085
1086        // List all transactions
1087        if let Some(CmdResult::TaggedTransactions {
1088            entities,
1089            pagination: Some(pagination),
1090        }) = ListTransactions::new().user_id(user.id).run().await?
1091        {
1092            assert_eq!(entities.len(), 1, "Expected one transaction");
1093            assert_eq!(pagination.total_count, 1);
1094
1095            let (entity, _tags, _amount) = &entities[0];
1096            if let FinanceEntity::Transaction(tx) = entity {
1097                assert_eq!(tx.id, tx_id);
1098            } else {
1099                panic!("Expected Transaction entity");
1100            }
1101        } else {
1102            panic!("Expected TaggedTransactions result with pagination");
1103        }
1104
1105        // List transactions filtered by account
1106        if let Some(CmdResult::TaggedTransactions { entities, .. }) = ListTransactions::new()
1107            .user_id(user.id)
1108            .account(account1.id)
1109            .run()
1110            .await?
1111        {
1112            assert_eq!(entities.len(), 1, "Expected one transaction for account1");
1113        } else {
1114            panic!("Expected TaggedTransactions result");
1115        }
1116
1117        // List transactions for non-existent account
1118        if let Some(CmdResult::TaggedTransactions { entities, .. }) = ListTransactions::new()
1119            .user_id(user.id)
1120            .account(Uuid::new_v4())
1121            .run()
1122            .await?
1123        {
1124            assert_eq!(
1125                entities.len(),
1126                0,
1127                "Expected no transactions for non-existent account"
1128            );
1129        } else {
1130            panic!("Expected TaggedTransactions result");
1131        }
1132    }
1133
1134    #[local_db_sqlx_test]
1135    async fn test_get_transaction(pool: PgPool) -> anyhow::Result<()> {
1136        let user = USER.get().unwrap();
1137        user.commit()
1138            .await
1139            .expect("Failed to commit user to database");
1140
1141        // First create a commodity
1142        let commodity_result = CreateCommodity::new()
1143            .symbol("TST".to_string())
1144            .name("Test Commodity".to_string())
1145            .user_id(user.id)
1146            .run()
1147            .await?;
1148
1149        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1150            uuid::Uuid::parse_str(&id)?
1151        } else {
1152            panic!("Expected commodity ID string result");
1153        };
1154
1155        // Create two accounts
1156        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1157            CreateAccount::new()
1158                .name("Account 1".to_string())
1159                .user_id(user.id)
1160                .run()
1161                .await?
1162        {
1163            account
1164        } else {
1165            panic!("Expected account entity result");
1166        };
1167
1168        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1169            CreateAccount::new()
1170                .name("Account 2".to_string())
1171                .user_id(user.id)
1172                .run()
1173                .await?
1174        {
1175            account
1176        } else {
1177            panic!("Expected account entity result");
1178        };
1179
1180        // Create a transaction
1181        let tx_id = Uuid::new_v4();
1182        let split1 = Split {
1183            id: Uuid::new_v4(),
1184            tx_id,
1185            account_id: account1.id,
1186            commodity_id,
1187            value_num: -100,
1188            value_denom: 1,
1189            reconcile_state: None,
1190            reconcile_date: None,
1191            lot_id: None,
1192        };
1193        let split2 = Split {
1194            id: Uuid::new_v4(),
1195            tx_id,
1196            account_id: account2.id,
1197            commodity_id,
1198            value_num: 100,
1199            value_denom: 1,
1200            reconcile_state: None,
1201            reconcile_date: None,
1202            lot_id: None,
1203        };
1204        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1205        let now = Utc::now();
1206
1207        CreateTransaction::new()
1208            .user_id(user.id)
1209            .splits(splits)
1210            .id(tx_id)
1211            .post_date(now)
1212            .enter_date(now)
1213            .note("Test transaction".to_string())
1214            .run()
1215            .await?;
1216
1217        // Test GetTransaction
1218        if let Some(CmdResult::TaggedTransactions { entities, .. }) = GetTransaction::new()
1219            .user_id(user.id)
1220            .transaction_id(tx_id)
1221            .run()
1222            .await?
1223        {
1224            assert_eq!(entities.len(), 1, "Expected one transaction");
1225            let (entity, _tags, _amount) = &entities[0];
1226            if let FinanceEntity::Transaction(tx) = entity {
1227                assert_eq!(tx.id, tx_id);
1228            } else {
1229                panic!("Expected Transaction entity");
1230            }
1231        } else {
1232            panic!("Expected TaggedTransactions result");
1233        }
1234
1235        // Test GetTransaction with non-existent ID
1236        let result = GetTransaction::new()
1237            .user_id(user.id)
1238            .transaction_id(Uuid::new_v4())
1239            .run()
1240            .await?;
1241        assert!(
1242            result.is_none(),
1243            "Expected None for non-existent transaction"
1244        );
1245    }
1246
1247    #[local_db_sqlx_test]
1248    async fn test_update_transaction(pool: PgPool) -> anyhow::Result<()> {
1249        let user = USER.get().unwrap();
1250        user.commit()
1251            .await
1252            .expect("Failed to commit user to database");
1253
1254        // First create a commodity
1255        let commodity_result = CreateCommodity::new()
1256            .symbol("TST".to_string())
1257            .name("Test Commodity".to_string())
1258            .user_id(user.id)
1259            .run()
1260            .await?;
1261
1262        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1263            uuid::Uuid::parse_str(&id)?
1264        } else {
1265            panic!("Expected commodity ID string result");
1266        };
1267
1268        // Create two accounts
1269        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1270            CreateAccount::new()
1271                .name("Account 1".to_string())
1272                .user_id(user.id)
1273                .run()
1274                .await?
1275        {
1276            account
1277        } else {
1278            panic!("Expected account entity result");
1279        };
1280
1281        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1282            CreateAccount::new()
1283                .name("Account 2".to_string())
1284                .user_id(user.id)
1285                .run()
1286                .await?
1287        {
1288            account
1289        } else {
1290            panic!("Expected account entity result");
1291        };
1292
1293        // Create a transaction
1294        let tx_id = Uuid::new_v4();
1295        let split1 = Split {
1296            id: Uuid::new_v4(),
1297            tx_id,
1298            account_id: account1.id,
1299            commodity_id,
1300            value_num: -100,
1301            value_denom: 1,
1302            reconcile_state: None,
1303            reconcile_date: None,
1304            lot_id: None,
1305        };
1306        let split2 = Split {
1307            id: Uuid::new_v4(),
1308            tx_id,
1309            account_id: account2.id,
1310            commodity_id,
1311            value_num: 100,
1312            value_denom: 1,
1313            reconcile_state: None,
1314            reconcile_date: None,
1315            lot_id: None,
1316        };
1317        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1318        let now = Utc::now();
1319
1320        CreateTransaction::new()
1321            .user_id(user.id)
1322            .splits(splits)
1323            .id(tx_id)
1324            .post_date(now)
1325            .enter_date(now)
1326            .note("Original note".to_string())
1327            .run()
1328            .await?;
1329
1330        // Test UpdateTransaction with only note change
1331        let new_note = "Updated note".to_string();
1332        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1333            UpdateTransaction::new()
1334                .user_id(user.id)
1335                .transaction_id(tx_id)
1336                .note(new_note.clone())
1337                .run()
1338                .await?
1339        {
1340            assert_eq!(updated_tx.id, tx_id);
1341        } else {
1342            panic!("Expected Transaction entity result");
1343        }
1344
1345        // Test UpdateTransaction with new splits
1346        let new_split1 = Split {
1347            id: Uuid::new_v4(),
1348            tx_id,
1349            account_id: account1.id,
1350            commodity_id,
1351            value_num: -200,
1352            value_denom: 1,
1353            reconcile_state: None,
1354            reconcile_date: None,
1355            lot_id: None,
1356        };
1357        let new_split2 = Split {
1358            id: Uuid::new_v4(),
1359            tx_id,
1360            account_id: account2.id,
1361            commodity_id,
1362            value_num: 200,
1363            value_denom: 1,
1364            reconcile_state: None,
1365            reconcile_date: None,
1366            lot_id: None,
1367        };
1368        let new_splits = vec![
1369            FinanceEntity::Split(new_split1),
1370            FinanceEntity::Split(new_split2),
1371        ];
1372
1373        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1374            UpdateTransaction::new()
1375                .user_id(user.id)
1376                .transaction_id(tx_id)
1377                .splits(new_splits)
1378                .run()
1379                .await?
1380        {
1381            assert_eq!(updated_tx.id, tx_id);
1382        } else {
1383            panic!("Expected Transaction entity result");
1384        }
1385
1386        // Test UpdateTransaction atomicity: unbalanced splits should fail
1387        let unbalanced_split1 = Split {
1388            id: Uuid::new_v4(),
1389            tx_id,
1390            account_id: account1.id,
1391            commodity_id,
1392            value_num: -100, // This doesn't balance with split2
1393            value_denom: 1,
1394            reconcile_state: None,
1395            reconcile_date: None,
1396            lot_id: None,
1397        };
1398        let unbalanced_split2 = Split {
1399            id: Uuid::new_v4(),
1400            tx_id,
1401            account_id: account2.id,
1402            commodity_id,
1403            value_num: 50, // Should be 100 to balance
1404            value_denom: 1,
1405            reconcile_state: None,
1406            reconcile_date: None,
1407            lot_id: None,
1408        };
1409        let unbalanced_splits = vec![
1410            FinanceEntity::Split(unbalanced_split1),
1411            FinanceEntity::Split(unbalanced_split2),
1412        ];
1413
1414        let result = UpdateTransaction::new()
1415            .user_id(user.id)
1416            .transaction_id(tx_id)
1417            .splits(unbalanced_splits)
1418            .run()
1419            .await;
1420        assert!(result.is_err(), "Expected error for unbalanced splits");
1421
1422        // Verify original transaction is unchanged after failed update
1423        if let Some(CmdResult::TaggedTransactions { entities, .. }) = GetTransaction::new()
1424            .user_id(user.id)
1425            .transaction_id(tx_id)
1426            .run()
1427            .await?
1428        {
1429            assert_eq!(entities.len(), 1, "Expected one transaction");
1430            // Transaction should still exist and be unchanged
1431        } else {
1432            panic!("Expected transaction to still exist after failed update");
1433        }
1434
1435        // Test UpdateTransaction with non-existent transaction
1436        let result = UpdateTransaction::new()
1437            .user_id(user.id)
1438            .transaction_id(Uuid::new_v4())
1439            .note("Should fail".to_string())
1440            .run()
1441            .await;
1442        assert!(
1443            result.is_err(),
1444            "Expected error for non-existent transaction"
1445        );
1446    }
1447
1448    #[local_db_sqlx_test]
1449    async fn test_update_transaction_atomicity(pool: PgPool) -> anyhow::Result<()> {
1450        let user = USER.get().unwrap();
1451        user.commit()
1452            .await
1453            .expect("Failed to commit user to database");
1454
1455        // First create a commodity
1456        let commodity_result = CreateCommodity::new()
1457            .symbol("TST".to_string())
1458            .name("Test Commodity".to_string())
1459            .user_id(user.id)
1460            .run()
1461            .await?;
1462
1463        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1464            uuid::Uuid::parse_str(&id)?
1465        } else {
1466            panic!("Expected commodity ID string result");
1467        };
1468
1469        // Create two accounts
1470        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1471            CreateAccount::new()
1472                .name("Account 1".to_string())
1473                .user_id(user.id)
1474                .run()
1475                .await?
1476        {
1477            account
1478        } else {
1479            panic!("Expected account entity result");
1480        };
1481
1482        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1483            CreateAccount::new()
1484                .name("Account 2".to_string())
1485                .user_id(user.id)
1486                .run()
1487                .await?
1488        {
1489            account
1490        } else {
1491            panic!("Expected account entity result");
1492        };
1493
1494        // Create a transaction
1495        let tx_id = Uuid::new_v4();
1496        let split1 = Split {
1497            id: Uuid::new_v4(),
1498            tx_id,
1499            account_id: account1.id,
1500            commodity_id,
1501            value_num: -100,
1502            value_denom: 1,
1503            reconcile_state: None,
1504            reconcile_date: None,
1505            lot_id: None,
1506        };
1507        let split2 = Split {
1508            id: Uuid::new_v4(),
1509            tx_id,
1510            account_id: account2.id,
1511            commodity_id,
1512            value_num: 100,
1513            value_denom: 1,
1514            reconcile_state: None,
1515            reconcile_date: None,
1516            lot_id: None,
1517        };
1518        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1519        let now = Utc::now();
1520
1521        CreateTransaction::new()
1522            .user_id(user.id)
1523            .splits(splits)
1524            .id(tx_id)
1525            .post_date(now)
1526            .enter_date(now)
1527            .note("Original transaction".to_string())
1528            .run()
1529            .await?;
1530
1531        // Test 1: Split transaction ID mismatch validation
1532        let wrong_tx_id = Uuid::new_v4();
1533        let invalid_split = Split {
1534            id: Uuid::new_v4(),
1535            tx_id: wrong_tx_id, // Wrong transaction ID
1536            account_id: account1.id,
1537            commodity_id,
1538            value_num: -50,
1539            value_denom: 1,
1540            reconcile_state: None,
1541            reconcile_date: None,
1542            lot_id: None,
1543        };
1544        let valid_split = Split {
1545            id: Uuid::new_v4(),
1546            tx_id,
1547            account_id: account2.id,
1548            commodity_id,
1549            value_num: 50,
1550            value_denom: 1,
1551            reconcile_state: None,
1552            reconcile_date: None,
1553            lot_id: None,
1554        };
1555        let mismatched_splits = vec![
1556            FinanceEntity::Split(invalid_split),
1557            FinanceEntity::Split(valid_split),
1558        ];
1559
1560        let result = UpdateTransaction::new()
1561            .user_id(user.id)
1562            .transaction_id(tx_id)
1563            .splits(mismatched_splits)
1564            .run()
1565            .await;
1566
1567        assert!(
1568            result.is_err(),
1569            "Expected error for split transaction ID mismatch"
1570        );
1571        if let Err(CmdError::Args(msg)) = result {
1572            assert!(msg.contains("Split transaction ID mismatch"));
1573        } else {
1574            panic!("Expected CmdError::Args with transaction ID mismatch message");
1575        }
1576
1577        // Verify original transaction is unchanged
1578        if let Some(CmdResult::TaggedTransactions { entities, .. }) = GetTransaction::new()
1579            .user_id(user.id)
1580            .transaction_id(tx_id)
1581            .run()
1582            .await?
1583        {
1584            assert_eq!(entities.len(), 1, "Expected one transaction");
1585        } else {
1586            panic!("Expected transaction to still exist after failed update");
1587        }
1588
1589        // Test 2: Invalid entity type in splits
1590        let invalid_splits = vec![
1591            FinanceEntity::Account(Account {
1592                id: account1.id,
1593                parent: account1.parent,
1594            }), // Wrong entity type
1595        ];
1596
1597        let result = UpdateTransaction::new()
1598            .user_id(user.id)
1599            .transaction_id(tx_id)
1600            .splits(invalid_splits)
1601            .run()
1602            .await;
1603
1604        assert!(
1605            result.is_err(),
1606            "Expected error for invalid entity type in splits"
1607        );
1608        if let Err(CmdError::Args(msg)) = result {
1609            assert!(msg.contains("Invalid entity type in splits"));
1610        } else {
1611            panic!("Expected CmdError::Args with invalid entity type message");
1612        }
1613
1614        // Test 3: Invalid entity type in prices
1615        let invalid_prices = vec![
1616            FinanceEntity::Account(Account {
1617                id: account1.id,
1618                parent: account1.parent,
1619            }), // Wrong entity type
1620        ];
1621
1622        let result = UpdateTransaction::new()
1623            .user_id(user.id)
1624            .transaction_id(tx_id)
1625            .prices(invalid_prices)
1626            .run()
1627            .await;
1628
1629        assert!(
1630            result.is_err(),
1631            "Expected error for invalid entity type in prices"
1632        );
1633        if let Err(CmdError::Args(msg)) = result {
1634            assert!(msg.contains("Invalid entity type in prices"));
1635        } else {
1636            panic!("Expected CmdError::Args with invalid entity type message");
1637        }
1638
1639        // Test 4: Invalid entity type in tags
1640        let mut invalid_tags = HashMap::new();
1641        invalid_tags.insert(
1642            "test".to_string(),
1643            FinanceEntity::Account(Account {
1644                id: account1.id,
1645                parent: account1.parent,
1646            }),
1647        );
1648
1649        let result = UpdateTransaction::new()
1650            .user_id(user.id)
1651            .transaction_id(tx_id)
1652            .tags(invalid_tags)
1653            .run()
1654            .await;
1655
1656        assert!(
1657            result.is_err(),
1658            "Expected error for invalid entity type in tags"
1659        );
1660        if let Err(CmdError::Args(msg)) = result {
1661            assert!(msg.contains("Invalid entity type in tags"));
1662        } else {
1663            panic!("Expected CmdError::Args with invalid entity type message");
1664        }
1665
1666        // Test 5: Database rollback verification - count splits before and after failed update
1667        let mut conn = user.get_connection().await?;
1668        let initial_split_count = sqlx::query!(
1669            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
1670            tx_id
1671        )
1672        .fetch_one(&mut *conn)
1673        .await?
1674        .count
1675        .unwrap_or(0);
1676
1677        // Try an update that will fail during split insertion (invalid account ID)
1678        let invalid_account_split = Split {
1679            id: Uuid::new_v4(),
1680            tx_id,
1681            account_id: Uuid::new_v4(), // Non-existent account
1682            commodity_id,
1683            value_num: -100,
1684            value_denom: 1,
1685            reconcile_state: None,
1686            reconcile_date: None,
1687            lot_id: None,
1688        };
1689        let balancing_split = Split {
1690            id: Uuid::new_v4(),
1691            tx_id,
1692            account_id: account2.id,
1693            commodity_id,
1694            value_num: 100,
1695            value_denom: 1,
1696            reconcile_state: None,
1697            reconcile_date: None,
1698            lot_id: None,
1699        };
1700        let failing_splits = vec![
1701            FinanceEntity::Split(invalid_account_split),
1702            FinanceEntity::Split(balancing_split),
1703        ];
1704
1705        let result = UpdateTransaction::new()
1706            .user_id(user.id)
1707            .transaction_id(tx_id)
1708            .splits(failing_splits)
1709            .run()
1710            .await;
1711
1712        assert!(result.is_err(), "Expected error for non-existent account");
1713
1714        // Verify splits count is unchanged (rollback occurred)
1715        let final_split_count = sqlx::query!(
1716            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
1717            tx_id
1718        )
1719        .fetch_one(&mut *conn)
1720        .await?
1721        .count
1722        .unwrap_or(0);
1723
1724        assert_eq!(
1725            initial_split_count, final_split_count,
1726            "Split count should be unchanged after failed update due to rollback"
1727        );
1728
1729        // Verify original transaction is still intact
1730        if let Some(CmdResult::TaggedTransactions { entities, .. }) = GetTransaction::new()
1731            .user_id(user.id)
1732            .transaction_id(tx_id)
1733            .run()
1734            .await?
1735        {
1736            assert_eq!(entities.len(), 1, "Expected one transaction");
1737        } else {
1738            panic!("Expected transaction to still exist after failed database operation");
1739        }
1740    }
1741
1742    #[local_db_sqlx_test]
1743    async fn test_update_transaction_prices_and_tags(pool: PgPool) -> anyhow::Result<()> {
1744        let user = USER.get().unwrap();
1745        user.commit()
1746            .await
1747            .expect("Failed to commit user to database");
1748
1749        // Create a simple commodity for testing
1750        let commodity_result = CreateCommodity::new()
1751            .symbol("TST".to_string())
1752            .name("Test Commodity".to_string())
1753            .user_id(user.id)
1754            .run()
1755            .await?;
1756
1757        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
1758            uuid::Uuid::parse_str(&id)?
1759        } else {
1760            panic!("Expected commodity ID string result");
1761        };
1762
1763        // Create accounts
1764        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1765            CreateAccount::new()
1766                .name("Account 1".to_string())
1767                .user_id(user.id)
1768                .run()
1769                .await?
1770        {
1771            account
1772        } else {
1773            panic!("Expected account entity result");
1774        };
1775
1776        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
1777            CreateAccount::new()
1778                .name("Account 2".to_string())
1779                .user_id(user.id)
1780                .run()
1781                .await?
1782        {
1783            account
1784        } else {
1785            panic!("Expected account entity result");
1786        };
1787
1788        // Create tags for testing manually
1789        let tag1_id = Uuid::new_v4();
1790        let tag2_id = Uuid::new_v4();
1791        let mut conn = user.get_connection().await?;
1792
1793        sqlx::query!(
1794            "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
1795            tag1_id,
1796            "category",
1797            "expense",
1798            Some("Expense category".to_string())
1799        )
1800        .execute(&mut *conn)
1801        .await?;
1802
1803        sqlx::query!(
1804            "INSERT INTO tags (id, tag_name, tag_value, description) VALUES ($1, $2, $3, $4)",
1805            tag2_id,
1806            "project",
1807            "finance_app",
1808            Some("Finance app project".to_string())
1809        )
1810        .execute(&mut *conn)
1811        .await?;
1812
1813        // Create initial transaction with simple same-commodity splits
1814        let tx_id = Uuid::new_v4();
1815        let split1 = Split {
1816            id: Uuid::new_v4(),
1817            tx_id,
1818            account_id: account1.id,
1819            commodity_id,
1820            value_num: -100,
1821            value_denom: 1,
1822            reconcile_state: None,
1823            reconcile_date: None,
1824            lot_id: None,
1825        };
1826        let split2 = Split {
1827            id: Uuid::new_v4(),
1828            tx_id,
1829            account_id: account2.id,
1830            commodity_id,
1831            value_num: 100,
1832            value_denom: 1,
1833            reconcile_state: None,
1834            reconcile_date: None,
1835            lot_id: None,
1836        };
1837        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
1838        let now = Utc::now();
1839
1840        CreateTransaction::new()
1841            .user_id(user.id)
1842            .splits(splits)
1843            .id(tx_id)
1844            .post_date(now)
1845            .enter_date(now)
1846            .note("Initial transaction".to_string())
1847            .run()
1848            .await?;
1849
1850        // Test 1: Update transaction with standalone prices (not linked to splits)
1851        let price1 = Price {
1852            id: Uuid::new_v4(),
1853            commodity_id,
1854            currency_id: commodity_id, // Same commodity for simplicity
1855            commodity_split: None,     // Not linked to specific splits
1856            currency_split: None,
1857            date: now,
1858            value_num: 100,
1859            value_denom: 100,
1860        };
1861
1862        let prices = vec![FinanceEntity::Price(price1)];
1863
1864        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1865            UpdateTransaction::new()
1866                .user_id(user.id)
1867                .transaction_id(tx_id)
1868                .prices(prices)
1869                .run()
1870                .await?
1871        {
1872            assert_eq!(updated_tx.id, tx_id);
1873        } else {
1874            panic!("Expected Transaction entity result for price update");
1875        }
1876
1877        // Verify prices were inserted
1878        let price_count = sqlx::query!(
1879            "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1 AND currency_id = $2",
1880            commodity_id,
1881            commodity_id
1882        )
1883        .fetch_one(&mut *conn)
1884        .await?
1885        .count
1886        .unwrap_or(0);
1887        assert_eq!(price_count, 1, "Expected one price record");
1888
1889        // Test 2: Update transaction with tags
1890        let mut tags = HashMap::new();
1891        tags.insert(
1892            "category".to_string(),
1893            FinanceEntity::Tag(Tag {
1894                id: tag1_id,
1895                tag_name: "category".to_string(),
1896                tag_value: "expense".to_string(),
1897                description: Some("Expense category".to_string()),
1898            }),
1899        );
1900        tags.insert(
1901            "project".to_string(),
1902            FinanceEntity::Tag(Tag {
1903                id: tag2_id,
1904                tag_name: "project".to_string(),
1905                tag_value: "finance_app".to_string(),
1906                description: Some("Finance app project".to_string()),
1907            }),
1908        );
1909
1910        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1911            UpdateTransaction::new()
1912                .user_id(user.id)
1913                .transaction_id(tx_id)
1914                .tags(tags)
1915                .run()
1916                .await?
1917        {
1918            assert_eq!(updated_tx.id, tx_id);
1919        } else {
1920            panic!("Expected Transaction entity result for tag update");
1921        }
1922
1923        // Verify tags were inserted
1924        let tag_count = sqlx::query!(
1925            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
1926            tx_id
1927        )
1928        .fetch_one(&mut *conn)
1929        .await?
1930        .count
1931        .unwrap_or(0);
1932        assert_eq!(tag_count, 2, "Expected two tag records");
1933
1934        // Test 3: Combined update (new splits, new prices, and new tags)
1935        let new_split1 = Split {
1936            id: Uuid::new_v4(),
1937            tx_id,
1938            account_id: account1.id,
1939            commodity_id,
1940            value_num: -200,
1941            value_denom: 1,
1942            reconcile_state: None,
1943            reconcile_date: None,
1944            lot_id: None,
1945        };
1946        let new_split2 = Split {
1947            id: Uuid::new_v4(),
1948            tx_id,
1949            account_id: account2.id,
1950            commodity_id,
1951            value_num: 200,
1952            value_denom: 1,
1953            reconcile_state: None,
1954            reconcile_date: None,
1955            lot_id: None,
1956        };
1957        let new_splits = vec![
1958            FinanceEntity::Split(new_split1),
1959            FinanceEntity::Split(new_split2),
1960        ];
1961
1962        let new_price = Price {
1963            id: Uuid::new_v4(),
1964            commodity_id,
1965            currency_id: commodity_id,
1966            commodity_split: None,
1967            currency_split: None,
1968            date: now,
1969            value_num: 110,
1970            value_denom: 100,
1971        };
1972        let new_prices = vec![FinanceEntity::Price(new_price)];
1973
1974        let mut new_tags = HashMap::new();
1975        new_tags.insert(
1976            "category".to_string(),
1977            FinanceEntity::Tag(Tag {
1978                id: tag1_id,
1979                tag_name: "category".to_string(),
1980                tag_value: "income".to_string(), // Changed value
1981                description: Some("Expense category".to_string()),
1982            }),
1983        );
1984
1985        if let Some(CmdResult::Entity(FinanceEntity::Transaction(updated_tx))) =
1986            UpdateTransaction::new()
1987                .user_id(user.id)
1988                .transaction_id(tx_id)
1989                .splits(new_splits)
1990                .prices(new_prices)
1991                .tags(new_tags)
1992                .run()
1993                .await?
1994        {
1995            assert_eq!(updated_tx.id, tx_id);
1996        } else {
1997            panic!("Expected Transaction entity result for combined update");
1998        }
1999
2000        // Verify all updates were applied atomically
2001        let final_split_count = sqlx::query!(
2002            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
2003            tx_id
2004        )
2005        .fetch_one(&mut *conn)
2006        .await?
2007        .count
2008        .unwrap_or(0);
2009        assert_eq!(final_split_count, 2, "Expected two splits after update");
2010
2011        let final_price_count = sqlx::query!(
2012            "SELECT COUNT(*) as count FROM prices WHERE commodity_id = $1",
2013            commodity_id
2014        )
2015        .fetch_one(&mut *conn)
2016        .await?
2017        .count
2018        .unwrap_or(0);
2019        assert_eq!(final_price_count, 2, "Expected two prices after update");
2020
2021        let final_tag_count = sqlx::query!(
2022            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
2023            tx_id
2024        )
2025        .fetch_one(&mut *conn)
2026        .await?
2027        .count
2028        .unwrap_or(0);
2029        assert_eq!(final_tag_count, 1, "Expected one tag after update");
2030
2031        // Test 4: Tag validation failure with non-existent tag
2032        let mut invalid_tags = HashMap::new();
2033        invalid_tags.insert(
2034            "invalid".to_string(),
2035            FinanceEntity::Tag(Tag {
2036                id: Uuid::new_v4(), // Non-existent tag
2037                tag_name: "invalid".to_string(),
2038                tag_value: "value".to_string(),
2039                description: Some("Invalid tag".to_string()),
2040            }),
2041        );
2042
2043        let result = UpdateTransaction::new()
2044            .user_id(user.id)
2045            .transaction_id(tx_id)
2046            .tags(invalid_tags)
2047            .run()
2048            .await;
2049
2050        assert!(
2051            result.is_err(),
2052            "Expected error for invalid tag with non-existent tag ID"
2053        );
2054
2055        // Verify original tags are unchanged after failed tag update
2056        let unchanged_tag_count = sqlx::query!(
2057            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
2058            tx_id
2059        )
2060        .fetch_one(&mut *conn)
2061        .await?
2062        .count
2063        .unwrap_or(0);
2064        assert_eq!(
2065            unchanged_tag_count, 1,
2066            "Tag count should be unchanged after failed update"
2067        );
2068    }
2069
2070    #[local_db_sqlx_test]
2071    async fn test_delete_transaction_simple(pool: PgPool) -> anyhow::Result<()> {
2072        let user = USER.get().unwrap();
2073        user.commit()
2074            .await
2075            .expect("Failed to commit user to database");
2076
2077        let commodity_result = CreateCommodity::new()
2078            .symbol("TST".to_string())
2079            .name("Test Commodity".to_string())
2080            .user_id(user.id)
2081            .run()
2082            .await?;
2083
2084        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2085            uuid::Uuid::parse_str(&id)?
2086        } else {
2087            panic!("Expected commodity ID string result");
2088        };
2089
2090        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2091            CreateAccount::new()
2092                .name("Account 1".to_string())
2093                .user_id(user.id)
2094                .run()
2095                .await?
2096        {
2097            account
2098        } else {
2099            panic!("Expected account entity result");
2100        };
2101
2102        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2103            CreateAccount::new()
2104                .name("Account 2".to_string())
2105                .user_id(user.id)
2106                .run()
2107                .await?
2108        {
2109            account
2110        } else {
2111            panic!("Expected account entity result");
2112        };
2113
2114        let tx_id = Uuid::new_v4();
2115        let split1 = Split {
2116            id: Uuid::new_v4(),
2117            tx_id,
2118            account_id: account1.id,
2119            commodity_id,
2120            value_num: -100,
2121            value_denom: 1,
2122            reconcile_state: None,
2123            reconcile_date: None,
2124            lot_id: None,
2125        };
2126        let split2 = Split {
2127            id: Uuid::new_v4(),
2128            tx_id,
2129            account_id: account2.id,
2130            commodity_id,
2131            value_num: 100,
2132            value_denom: 1,
2133            reconcile_state: None,
2134            reconcile_date: None,
2135            lot_id: None,
2136        };
2137        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2138        let now = Utc::now();
2139
2140        CreateTransaction::new()
2141            .user_id(user.id)
2142            .splits(splits)
2143            .id(tx_id)
2144            .post_date(now)
2145            .enter_date(now)
2146            .run()
2147            .await?;
2148
2149        let result = DeleteTransaction::new()
2150            .user_id(user.id)
2151            .transaction_id(tx_id)
2152            .run()
2153            .await?;
2154
2155        assert!(result.is_some(), "Expected successful deletion");
2156
2157        let mut conn = user.get_connection().await?;
2158        let tx_exists = sqlx::query!(
2159            "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
2160            tx_id
2161        )
2162        .fetch_one(&mut *conn)
2163        .await?
2164        .count
2165        .unwrap_or(0);
2166        assert_eq!(tx_exists, 0, "Transaction should be deleted");
2167
2168        let splits_exist = sqlx::query!(
2169            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
2170            tx_id
2171        )
2172        .fetch_one(&mut *conn)
2173        .await?
2174        .count
2175        .unwrap_or(0);
2176        assert_eq!(splits_exist, 0, "Splits should be deleted");
2177    }
2178
2179    #[local_db_sqlx_test]
2180    async fn test_delete_transaction_with_tags_and_prices(pool: PgPool) -> anyhow::Result<()> {
2181        let user = USER.get().unwrap();
2182        user.commit()
2183            .await
2184            .expect("Failed to commit user to database");
2185
2186        let commodity_result = CreateCommodity::new()
2187            .symbol("TST".to_string())
2188            .name("Test Commodity".to_string())
2189            .user_id(user.id)
2190            .run()
2191            .await?;
2192
2193        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2194            uuid::Uuid::parse_str(&id)?
2195        } else {
2196            panic!("Expected commodity ID string result");
2197        };
2198
2199        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2200            CreateAccount::new()
2201                .name("Account 1".to_string())
2202                .user_id(user.id)
2203                .run()
2204                .await?
2205        {
2206            account
2207        } else {
2208            panic!("Expected account entity result");
2209        };
2210
2211        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2212            CreateAccount::new()
2213                .name("Account 2".to_string())
2214                .user_id(user.id)
2215                .run()
2216                .await?
2217        {
2218            account
2219        } else {
2220            panic!("Expected account entity result");
2221        };
2222
2223        let tx_id = Uuid::new_v4();
2224        let split1_id = Uuid::new_v4();
2225        let split2_id = Uuid::new_v4();
2226        let split1 = Split {
2227            id: split1_id,
2228            tx_id,
2229            account_id: account1.id,
2230            commodity_id,
2231            value_num: -100,
2232            value_denom: 1,
2233            reconcile_state: None,
2234            reconcile_date: None,
2235            lot_id: None,
2236        };
2237        let split2 = Split {
2238            id: split2_id,
2239            tx_id,
2240            account_id: account2.id,
2241            commodity_id,
2242            value_num: 100,
2243            value_denom: 1,
2244            reconcile_state: None,
2245            reconcile_date: None,
2246            lot_id: None,
2247        };
2248        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2249        let now = Utc::now();
2250
2251        let price = Price {
2252            id: Uuid::new_v4(),
2253            commodity_id,
2254            currency_id: commodity_id,
2255            commodity_split: Some(split1_id),
2256            currency_split: Some(split2_id),
2257            date: now,
2258            value_num: 100,
2259            value_denom: 100,
2260        };
2261
2262        CreateTransaction::new()
2263            .user_id(user.id)
2264            .splits(splits)
2265            .id(tx_id)
2266            .post_date(now)
2267            .enter_date(now)
2268            .prices(vec![FinanceEntity::Price(price)])
2269            .note("Test note".to_string())
2270            .run()
2271            .await?;
2272
2273        let mut conn = user.get_connection().await?;
2274
2275        let tag_count_before = sqlx::query!("SELECT COUNT(*) as count FROM tags")
2276            .fetch_one(&mut *conn)
2277            .await?
2278            .count
2279            .unwrap_or(0);
2280        assert!(tag_count_before > 0, "Should have tags before deletion");
2281
2282        let price_count_before = sqlx::query!(
2283            "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
2284            split1_id,
2285            split2_id
2286        )
2287        .fetch_one(&mut *conn)
2288        .await?
2289        .count
2290        .unwrap_or(0);
2291        assert_eq!(
2292            price_count_before, 1,
2293            "Should have one price before deletion"
2294        );
2295
2296        let result = DeleteTransaction::new()
2297            .user_id(user.id)
2298            .transaction_id(tx_id)
2299            .run()
2300            .await?;
2301
2302        assert!(result.is_some(), "Expected successful deletion");
2303
2304        let tx_exists = sqlx::query!(
2305            "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
2306            tx_id
2307        )
2308        .fetch_one(&mut *conn)
2309        .await?
2310        .count
2311        .unwrap_or(0);
2312        assert_eq!(tx_exists, 0, "Transaction should be deleted");
2313
2314        let splits_exist = sqlx::query!(
2315            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
2316            tx_id
2317        )
2318        .fetch_one(&mut *conn)
2319        .await?
2320        .count
2321        .unwrap_or(0);
2322        assert_eq!(splits_exist, 0, "Splits should be deleted");
2323
2324        let tx_tags_exist = sqlx::query!(
2325            "SELECT COUNT(*) as count FROM transaction_tags WHERE tx_id = $1",
2326            tx_id
2327        )
2328        .fetch_one(&mut *conn)
2329        .await?
2330        .count
2331        .unwrap_or(0);
2332        assert_eq!(
2333            tx_tags_exist, 0,
2334            "Transaction tags associations should be deleted"
2335        );
2336
2337        let prices_exist = sqlx::query!(
2338            "SELECT COUNT(*) as count FROM prices WHERE commodity_split_id = $1 OR currency_split_id = $2",
2339            split1_id,
2340            split2_id
2341        )
2342        .fetch_one(&mut *conn)
2343        .await?
2344        .count
2345        .unwrap_or(0);
2346        assert_eq!(prices_exist, 0, "Prices should be deleted");
2347    }
2348
2349    #[local_db_sqlx_test]
2350    async fn test_delete_transaction_nonexistent(pool: PgPool) -> anyhow::Result<()> {
2351        let user = USER.get().unwrap();
2352        user.commit()
2353            .await
2354            .expect("Failed to commit user to database");
2355
2356        let nonexistent_id = Uuid::new_v4();
2357        let result = DeleteTransaction::new()
2358            .user_id(user.id)
2359            .transaction_id(nonexistent_id)
2360            .run()
2361            .await;
2362
2363        assert!(
2364            result.is_err(),
2365            "Expected error for non-existent transaction"
2366        );
2367        if let Err(CmdError::Args(msg)) = result {
2368            assert!(msg.contains("Transaction not found"));
2369        } else {
2370            panic!("Expected CmdError::Args with 'Transaction not found' message");
2371        }
2372    }
2373
2374    #[local_db_sqlx_test]
2375    async fn test_delete_transaction_orphaned_tags(pool: PgPool) -> anyhow::Result<()> {
2376        let user = USER.get().unwrap();
2377        user.commit()
2378            .await
2379            .expect("Failed to commit user to database");
2380
2381        let commodity_result = CreateCommodity::new()
2382            .symbol("TST".to_string())
2383            .name("Test Commodity".to_string())
2384            .user_id(user.id)
2385            .run()
2386            .await?;
2387
2388        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2389            uuid::Uuid::parse_str(&id)?
2390        } else {
2391            panic!("Expected commodity ID string result");
2392        };
2393
2394        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2395            CreateAccount::new()
2396                .name("Account 1".to_string())
2397                .user_id(user.id)
2398                .run()
2399                .await?
2400        {
2401            account
2402        } else {
2403            panic!("Expected account entity result");
2404        };
2405
2406        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2407            CreateAccount::new()
2408                .name("Account 2".to_string())
2409                .user_id(user.id)
2410                .run()
2411                .await?
2412        {
2413            account
2414        } else {
2415            panic!("Expected account entity result");
2416        };
2417
2418        let tx_id = Uuid::new_v4();
2419        let split1 = Split {
2420            id: Uuid::new_v4(),
2421            tx_id,
2422            account_id: account1.id,
2423            commodity_id,
2424            value_num: -100,
2425            value_denom: 1,
2426            reconcile_state: None,
2427            reconcile_date: None,
2428            lot_id: None,
2429        };
2430        let split2 = Split {
2431            id: Uuid::new_v4(),
2432            tx_id,
2433            account_id: account2.id,
2434            commodity_id,
2435            value_num: 100,
2436            value_denom: 1,
2437            reconcile_state: None,
2438            reconcile_date: None,
2439            lot_id: None,
2440        };
2441        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2442        let now = Utc::now();
2443
2444        CreateTransaction::new()
2445            .user_id(user.id)
2446            .splits(splits)
2447            .id(tx_id)
2448            .post_date(now)
2449            .enter_date(now)
2450            .note("Orphaned tag test".to_string())
2451            .run()
2452            .await?;
2453
2454        let mut conn = user.get_connection().await?;
2455
2456        let tag_id = sqlx::query!(
2457            "SELECT tag_id FROM transaction_tags WHERE tx_id = $1",
2458            tx_id
2459        )
2460        .fetch_one(&mut *conn)
2461        .await?
2462        .tag_id;
2463
2464        DeleteTransaction::new()
2465            .user_id(user.id)
2466            .transaction_id(tx_id)
2467            .run()
2468            .await?;
2469
2470        let orphaned_tag_exists =
2471            sqlx::query!("SELECT COUNT(*) as count FROM tags WHERE id = $1", tag_id)
2472                .fetch_one(&mut *conn)
2473                .await?
2474                .count
2475                .unwrap_or(0);
2476        assert_eq!(orphaned_tag_exists, 0, "Orphaned tag should be deleted");
2477    }
2478
2479    const GROCERIES_SCRIPT_WASM: &[u8] =
2480        include_bytes!("../../../web/static/wasm/groceries_markup.wasm");
2481
2482    const TAG_SYNC_SCRIPT_WASM: &[u8] = include_bytes!("../../../web/static/wasm/tag_sync.wasm");
2483
2484    #[local_db_sqlx_test]
2485    async fn test_create_transaction_with_all_scripts_completes(
2486        pool: PgPool,
2487    ) -> anyhow::Result<()> {
2488        let user = USER.get().unwrap();
2489        user.commit()
2490            .await
2491            .expect("Failed to commit user to database");
2492
2493        let mut conn = user.get_connection().await?;
2494
2495        let groceries_script_id = user
2496            .create_script(GROCERIES_SCRIPT_WASM.to_vec(), None)
2497            .await?;
2498        let tag_sync_script_id = user
2499            .create_script(TAG_SYNC_SCRIPT_WASM.to_vec(), None)
2500            .await?;
2501
2502        let commodity_result = CreateCommodity::new()
2503            .symbol("TST".to_string())
2504            .name("Test Commodity".to_string())
2505            .user_id(user.id)
2506            .run()
2507            .await?;
2508
2509        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2510            uuid::Uuid::parse_str(&id)?
2511        } else {
2512            panic!("Expected commodity ID string result");
2513        };
2514
2515        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2516            CreateAccount::new()
2517                .name("Account 1".to_string())
2518                .user_id(user.id)
2519                .run()
2520                .await?
2521        {
2522            account
2523        } else {
2524            panic!("Expected account entity result");
2525        };
2526
2527        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2528            CreateAccount::new()
2529                .name("Account 2".to_string())
2530                .user_id(user.id)
2531                .run()
2532                .await?
2533        {
2534            account
2535        } else {
2536            panic!("Expected account entity result");
2537        };
2538
2539        let tx_id = Uuid::new_v4();
2540        let split1_id = Uuid::new_v4();
2541        let split2_id = Uuid::new_v4();
2542
2543        let split1 = Split {
2544            id: split1_id,
2545            tx_id,
2546            account_id: account1.id,
2547            commodity_id,
2548            value_num: -5000,
2549            value_denom: 100,
2550            reconcile_state: None,
2551            reconcile_date: None,
2552            lot_id: None,
2553        };
2554
2555        let split2 = Split {
2556            id: split2_id,
2557            tx_id,
2558            account_id: account2.id,
2559            commodity_id,
2560            value_num: 5000,
2561            value_denom: 100,
2562            reconcile_state: None,
2563            reconcile_date: None,
2564            lot_id: None,
2565        };
2566
2567        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2568        let now = Utc::now();
2569
2570        // A liveness guard, not a performance budget: it exists so a script that
2571        // deadlocks fails the suite instead of hanging it. The bound has to hold
2572        // on the slowest machine that runs this, which is a 4-vCPU CI node under
2573        // -Cinstrument-coverage executing two wasm scripts — 10s failed there
2574        // while passing everywhere else, which is a flaky test, not a finding.
2575        let result = tokio::time::timeout(
2576            std::time::Duration::from_secs(120),
2577            CreateTransaction::new()
2578                .user_id(user.id)
2579                .splits(splits)
2580                .id(tx_id)
2581                .post_date(now)
2582                .enter_date(now)
2583                .note("groceries".to_string())
2584                .run(),
2585        )
2586        .await;
2587
2588        assert!(
2589            result.is_ok(),
2590            "Transaction creation with scripts hung (>120s) — a script or lock is stuck"
2591        );
2592        result.unwrap()?;
2593
2594        let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
2595            .fetch_all(&mut *conn)
2596            .await?;
2597
2598        let split1_has_category = split1_tags
2599            .iter()
2600            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2601        assert!(
2602            split1_has_category,
2603            "Split 1 should have category=groceries tag from groceries script"
2604        );
2605
2606        user.delete_script(groceries_script_id).await?;
2607        user.delete_script(tag_sync_script_id).await?;
2608    }
2609
2610    #[local_db_sqlx_test]
2611    async fn test_create_transaction_with_script(pool: PgPool) -> anyhow::Result<()> {
2612        let user = USER.get().unwrap();
2613        user.commit()
2614            .await
2615            .expect("Failed to commit user to database");
2616
2617        let mut conn = user.get_connection().await?;
2618
2619        // Insert the groceries script into the database
2620        let script_id = user
2621            .create_script(GROCERIES_SCRIPT_WASM.to_vec(), None)
2622            .await?;
2623
2624        // Create commodity and accounts
2625        let commodity_result = CreateCommodity::new()
2626            .symbol("TST".to_string())
2627            .name("Test Commodity".to_string())
2628            .user_id(user.id)
2629            .run()
2630            .await?;
2631
2632        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2633            uuid::Uuid::parse_str(&id)?
2634        } else {
2635            panic!("Expected commodity ID string result");
2636        };
2637
2638        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2639            CreateAccount::new()
2640                .name("Account 1".to_string())
2641                .user_id(user.id)
2642                .run()
2643                .await?
2644        {
2645            account
2646        } else {
2647            panic!("Expected account entity result");
2648        };
2649
2650        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2651            CreateAccount::new()
2652                .name("Account 2".to_string())
2653                .user_id(user.id)
2654                .run()
2655                .await?
2656        {
2657            account
2658        } else {
2659            panic!("Expected account entity result");
2660        };
2661
2662        let tx_id = Uuid::new_v4();
2663        let split1_id = Uuid::new_v4();
2664        let split2_id = Uuid::new_v4();
2665
2666        let split1 = Split {
2667            id: split1_id,
2668            tx_id,
2669            account_id: account1.id,
2670            commodity_id,
2671            value_num: -5000,
2672            value_denom: 100,
2673            reconcile_state: None,
2674            reconcile_date: None,
2675            lot_id: None,
2676        };
2677
2678        let split2 = Split {
2679            id: split2_id,
2680            tx_id,
2681            account_id: account2.id,
2682            commodity_id,
2683            value_num: 5000,
2684            value_denom: 100,
2685            reconcile_state: None,
2686            reconcile_date: None,
2687            lot_id: None,
2688        };
2689
2690        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2691        let now = Utc::now();
2692
2693        // Create transaction with note="groceries" - this should trigger the script
2694        CreateTransaction::new()
2695            .user_id(user.id)
2696            .splits(splits)
2697            .id(tx_id)
2698            .post_date(now)
2699            .enter_date(now)
2700            .note("groceries".to_string())
2701            .run()
2702            .await?;
2703
2704        // Verify that the script added "category=groceries" tags to both splits
2705        let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
2706            .fetch_all(&mut *conn)
2707            .await?;
2708
2709        let split2_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split2_id)
2710            .fetch_all(&mut *conn)
2711            .await?;
2712
2713        // Check split1 has the category tag
2714        let split1_has_category = split1_tags
2715            .iter()
2716            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2717        assert!(
2718            split1_has_category,
2719            "Split 1 should have category=groceries tag from script. Tags: {:?}",
2720            split1_tags
2721                .iter()
2722                .map(|t| format!("{}={}", t.tag_name, t.tag_value))
2723                .collect::<Vec<_>>()
2724        );
2725
2726        // Check split2 has the category tag
2727        let split2_has_category = split2_tags
2728            .iter()
2729            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2730        assert!(
2731            split2_has_category,
2732            "Split 2 should have category=groceries tag from script. Tags: {:?}",
2733            split2_tags
2734                .iter()
2735                .map(|t| format!("{}={}", t.tag_name, t.tag_value))
2736                .collect::<Vec<_>>()
2737        );
2738
2739        // Clean up the script
2740        user.delete_script(script_id).await?;
2741    }
2742
2743    #[local_db_sqlx_test]
2744    async fn test_create_transaction_script_skips_non_matching(pool: PgPool) -> anyhow::Result<()> {
2745        let user = USER.get().unwrap();
2746        user.commit()
2747            .await
2748            .expect("Failed to commit user to database");
2749
2750        let mut conn = user.get_connection().await?;
2751
2752        // Insert the groceries script into the database
2753        let script_id = user
2754            .create_script(GROCERIES_SCRIPT_WASM.to_vec(), None)
2755            .await?;
2756
2757        // Create commodity and accounts
2758        let commodity_result = CreateCommodity::new()
2759            .symbol("TST".to_string())
2760            .name("Test Commodity".to_string())
2761            .user_id(user.id)
2762            .run()
2763            .await?;
2764
2765        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2766            uuid::Uuid::parse_str(&id)?
2767        } else {
2768            panic!("Expected commodity ID string result");
2769        };
2770
2771        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2772            CreateAccount::new()
2773                .name("Account 1".to_string())
2774                .user_id(user.id)
2775                .run()
2776                .await?
2777        {
2778            account
2779        } else {
2780            panic!("Expected account entity result");
2781        };
2782
2783        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2784            CreateAccount::new()
2785                .name("Account 2".to_string())
2786                .user_id(user.id)
2787                .run()
2788                .await?
2789        {
2790            account
2791        } else {
2792            panic!("Expected account entity result");
2793        };
2794
2795        let tx_id = Uuid::new_v4();
2796        let split1_id = Uuid::new_v4();
2797        let split2_id = Uuid::new_v4();
2798
2799        let split1 = Split {
2800            id: split1_id,
2801            tx_id,
2802            account_id: account1.id,
2803            commodity_id,
2804            value_num: -5000,
2805            value_denom: 100,
2806            reconcile_state: None,
2807            reconcile_date: None,
2808            lot_id: None,
2809        };
2810
2811        let split2 = Split {
2812            id: split2_id,
2813            tx_id,
2814            account_id: account2.id,
2815            commodity_id,
2816            value_num: 5000,
2817            value_denom: 100,
2818            reconcile_state: None,
2819            reconcile_date: None,
2820            lot_id: None,
2821        };
2822
2823        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2824        let now = Utc::now();
2825
2826        // Create transaction with note="other" - this should NOT trigger the script
2827        CreateTransaction::new()
2828            .user_id(user.id)
2829            .splits(splits)
2830            .id(tx_id)
2831            .post_date(now)
2832            .enter_date(now)
2833            .note("other".to_string())
2834            .run()
2835            .await?;
2836
2837        // Verify that the script did NOT add any tags to splits
2838        let split1_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split1_id)
2839            .fetch_all(&mut *conn)
2840            .await?;
2841
2842        let split2_tags = sqlx::query_file!("sql/select/tags/by_split.sql", split2_id)
2843            .fetch_all(&mut *conn)
2844            .await?;
2845
2846        // Neither split should have the category tag
2847        let split1_has_category = split1_tags
2848            .iter()
2849            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2850        assert!(
2851            !split1_has_category,
2852            "Split 1 should NOT have category tag for non-groceries transaction"
2853        );
2854
2855        let split2_has_category = split2_tags
2856            .iter()
2857            .any(|t| t.tag_name == "category" && t.tag_value == "groceries");
2858        assert!(
2859            !split2_has_category,
2860            "Split 2 should NOT have category tag for non-groceries transaction"
2861        );
2862
2863        // Clean up the script
2864        user.delete_script(script_id).await?;
2865    }
2866
2867    async fn create_test_transaction(
2868        user: &User,
2869        account1_id: Uuid,
2870        account2_id: Uuid,
2871        commodity_id: Uuid,
2872        post_date: DateTime<Utc>,
2873        amount: i64,
2874    ) -> anyhow::Result<Uuid> {
2875        let tx_id = Uuid::new_v4();
2876        let split1 = Split {
2877            id: Uuid::new_v4(),
2878            tx_id,
2879            account_id: account1_id,
2880            commodity_id,
2881            value_num: -amount,
2882            value_denom: 1,
2883            reconcile_state: None,
2884            reconcile_date: None,
2885            lot_id: None,
2886        };
2887        let split2 = Split {
2888            id: Uuid::new_v4(),
2889            tx_id,
2890            account_id: account2_id,
2891            commodity_id,
2892            value_num: amount,
2893            value_denom: 1,
2894            reconcile_state: None,
2895            reconcile_date: None,
2896            lot_id: None,
2897        };
2898        let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
2899
2900        CreateTransaction::new()
2901            .user_id(user.id)
2902            .splits(splits)
2903            .id(tx_id)
2904            .post_date(post_date)
2905            .enter_date(Utc::now())
2906            .run()
2907            .await?;
2908
2909        Ok(tx_id)
2910    }
2911
2912    #[local_db_sqlx_test]
2913    async fn test_pagination_limit(pool: PgPool) -> anyhow::Result<()> {
2914        let user = USER.get().unwrap();
2915        user.commit()
2916            .await
2917            .expect("Failed to commit user to database");
2918
2919        let commodity_result = CreateCommodity::new()
2920            .symbol("TST".to_string())
2921            .name("Test Commodity".to_string())
2922            .user_id(user.id)
2923            .run()
2924            .await?;
2925
2926        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
2927            uuid::Uuid::parse_str(&id)?
2928        } else {
2929            panic!("Expected commodity ID string result");
2930        };
2931
2932        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2933            CreateAccount::new()
2934                .name("Account 1".to_string())
2935                .user_id(user.id)
2936                .run()
2937                .await?
2938        {
2939            account
2940        } else {
2941            panic!("Expected account entity result");
2942        };
2943
2944        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
2945            CreateAccount::new()
2946                .name("Account 2".to_string())
2947                .user_id(user.id)
2948                .run()
2949                .await?
2950        {
2951            account
2952        } else {
2953            panic!("Expected account entity result");
2954        };
2955
2956        let base_time = Utc::now();
2957        for i in 0..25 {
2958            let post_date = base_time - Duration::days(i);
2959            create_test_transaction(
2960                user,
2961                account1.id,
2962                account2.id,
2963                commodity_id,
2964                post_date,
2965                100 + i,
2966            )
2967            .await?;
2968        }
2969
2970        // Test limit=10 returns exactly 10 transactions
2971        if let Some(CmdResult::TaggedTransactions {
2972            entities,
2973            pagination: Some(pagination),
2974        }) = ListTransactions::new()
2975            .user_id(user.id)
2976            .limit(10)
2977            .run()
2978            .await?
2979        {
2980            assert_eq!(entities.len(), 10, "Expected exactly 10 transactions");
2981            assert_eq!(pagination.total_count, 25);
2982            assert_eq!(pagination.limit, 10);
2983            assert_eq!(pagination.offset, 0);
2984            assert!(pagination.has_more);
2985        } else {
2986            panic!("Expected TaggedTransactions result with pagination");
2987        }
2988
2989        // Test limit=5 returns exactly 5 transactions
2990        if let Some(CmdResult::TaggedTransactions {
2991            entities,
2992            pagination: Some(pagination),
2993        }) = ListTransactions::new()
2994            .user_id(user.id)
2995            .limit(5)
2996            .run()
2997            .await?
2998        {
2999            assert_eq!(entities.len(), 5, "Expected exactly 5 transactions");
3000            assert_eq!(pagination.total_count, 25);
3001            assert!(pagination.has_more);
3002        } else {
3003            panic!("Expected TaggedTransactions result with pagination");
3004        }
3005
3006        // Test limit=100 returns all 25 transactions (limit > total)
3007        if let Some(CmdResult::TaggedTransactions {
3008            entities,
3009            pagination: Some(pagination),
3010        }) = ListTransactions::new()
3011            .user_id(user.id)
3012            .limit(100)
3013            .run()
3014            .await?
3015        {
3016            assert_eq!(entities.len(), 25, "Expected all 25 transactions");
3017            assert_eq!(pagination.total_count, 25);
3018            assert!(!pagination.has_more);
3019        } else {
3020            panic!("Expected TaggedTransactions result with pagination");
3021        }
3022    }
3023
3024    #[local_db_sqlx_test]
3025    async fn test_pagination_offset(pool: PgPool) -> anyhow::Result<()> {
3026        let user = USER.get().unwrap();
3027        user.commit()
3028            .await
3029            .expect("Failed to commit user to database");
3030
3031        let commodity_result = CreateCommodity::new()
3032            .symbol("TST".to_string())
3033            .name("Test Commodity".to_string())
3034            .user_id(user.id)
3035            .run()
3036            .await?;
3037
3038        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
3039            uuid::Uuid::parse_str(&id)?
3040        } else {
3041            panic!("Expected commodity ID string result");
3042        };
3043
3044        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3045            CreateAccount::new()
3046                .name("Account 1".to_string())
3047                .user_id(user.id)
3048                .run()
3049                .await?
3050        {
3051            account
3052        } else {
3053            panic!("Expected account entity result");
3054        };
3055
3056        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3057            CreateAccount::new()
3058                .name("Account 2".to_string())
3059                .user_id(user.id)
3060                .run()
3061                .await?
3062        {
3063            account
3064        } else {
3065            panic!("Expected account entity result");
3066        };
3067
3068        let base_time = Utc::now();
3069        for i in 0..25 {
3070            let post_date = base_time - Duration::days(i);
3071            create_test_transaction(
3072                user,
3073                account1.id,
3074                account2.id,
3075                commodity_id,
3076                post_date,
3077                100 + i,
3078            )
3079            .await?;
3080        }
3081
3082        // Test offset=10, limit=10 returns second page
3083        if let Some(CmdResult::TaggedTransactions {
3084            entities,
3085            pagination: Some(pagination),
3086        }) = ListTransactions::new()
3087            .user_id(user.id)
3088            .limit(10)
3089            .offset(10)
3090            .run()
3091            .await?
3092        {
3093            assert_eq!(
3094                entities.len(),
3095                10,
3096                "Expected 10 transactions on second page"
3097            );
3098            assert_eq!(pagination.total_count, 25);
3099            assert_eq!(pagination.offset, 10);
3100            assert!(pagination.has_more);
3101        } else {
3102            panic!("Expected TaggedTransactions result with pagination");
3103        }
3104
3105        // Test offset=20, limit=10 returns last page (only 5 remaining)
3106        if let Some(CmdResult::TaggedTransactions {
3107            entities,
3108            pagination: Some(pagination),
3109        }) = ListTransactions::new()
3110            .user_id(user.id)
3111            .limit(10)
3112            .offset(20)
3113            .run()
3114            .await?
3115        {
3116            assert_eq!(entities.len(), 5, "Expected 5 transactions on last page");
3117            assert_eq!(pagination.total_count, 25);
3118            assert!(!pagination.has_more);
3119        } else {
3120            panic!("Expected TaggedTransactions result with pagination");
3121        }
3122
3123        // Test offset beyond total returns empty
3124        if let Some(CmdResult::TaggedTransactions {
3125            entities,
3126            pagination: Some(pagination),
3127        }) = ListTransactions::new()
3128            .user_id(user.id)
3129            .limit(10)
3130            .offset(100)
3131            .run()
3132            .await?
3133        {
3134            assert!(entities.is_empty(), "Expected no transactions beyond total");
3135            assert_eq!(pagination.total_count, 25);
3136            assert!(!pagination.has_more);
3137        } else {
3138            panic!("Expected TaggedTransactions result with pagination");
3139        }
3140    }
3141
3142    #[local_db_sqlx_test]
3143    async fn test_pagination_date_filter(pool: PgPool) -> anyhow::Result<()> {
3144        let user = USER.get().unwrap();
3145        user.commit()
3146            .await
3147            .expect("Failed to commit user to database");
3148
3149        let commodity_result = CreateCommodity::new()
3150            .symbol("TST".to_string())
3151            .name("Test Commodity".to_string())
3152            .user_id(user.id)
3153            .run()
3154            .await?;
3155
3156        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
3157            uuid::Uuid::parse_str(&id)?
3158        } else {
3159            panic!("Expected commodity ID string result");
3160        };
3161
3162        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3163            CreateAccount::new()
3164                .name("Account 1".to_string())
3165                .user_id(user.id)
3166                .run()
3167                .await?
3168        {
3169            account
3170        } else {
3171            panic!("Expected account entity result");
3172        };
3173
3174        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3175            CreateAccount::new()
3176                .name("Account 2".to_string())
3177                .user_id(user.id)
3178                .run()
3179                .await?
3180        {
3181            account
3182        } else {
3183            panic!("Expected account entity result");
3184        };
3185
3186        let base_time = Utc::now();
3187        for i in 0..30 {
3188            let post_date = base_time - Duration::days(i);
3189            create_test_transaction(
3190                user,
3191                account1.id,
3192                account2.id,
3193                commodity_id,
3194                post_date,
3195                100 + i,
3196            )
3197            .await?;
3198        }
3199
3200        // Test date_from filter (last 10 days)
3201        let date_from = base_time - Duration::days(9);
3202        if let Some(CmdResult::TaggedTransactions {
3203            entities,
3204            pagination: Some(pagination),
3205        }) = ListTransactions::new()
3206            .user_id(user.id)
3207            .date_from(date_from)
3208            .run()
3209            .await?
3210        {
3211            assert_eq!(
3212                entities.len(),
3213                10,
3214                "Expected 10 transactions from last 10 days"
3215            );
3216            assert_eq!(pagination.total_count, 10);
3217        } else {
3218            panic!("Expected TaggedTransactions result with pagination");
3219        }
3220
3221        // Test date_to filter (older than 20 days)
3222        let date_to = base_time - Duration::days(20);
3223        if let Some(CmdResult::TaggedTransactions {
3224            entities,
3225            pagination: Some(pagination),
3226        }) = ListTransactions::new()
3227            .user_id(user.id)
3228            .date_to(date_to)
3229            .run()
3230            .await?
3231        {
3232            assert_eq!(
3233                entities.len(),
3234                10,
3235                "Expected 10 transactions older than 20 days"
3236            );
3237            assert_eq!(pagination.total_count, 10);
3238        } else {
3239            panic!("Expected TaggedTransactions result with pagination");
3240        }
3241
3242        // Test date range filter (days 10-19)
3243        let date_from = base_time - Duration::days(19);
3244        let date_to = base_time - Duration::days(10);
3245        if let Some(CmdResult::TaggedTransactions {
3246            entities,
3247            pagination: Some(pagination),
3248        }) = ListTransactions::new()
3249            .user_id(user.id)
3250            .date_from(date_from)
3251            .date_to(date_to)
3252            .run()
3253            .await?
3254        {
3255            assert_eq!(entities.len(), 10, "Expected 10 transactions in date range");
3256            assert_eq!(pagination.total_count, 10);
3257        } else {
3258            panic!("Expected TaggedTransactions result with pagination");
3259        }
3260
3261        // Test date range with pagination
3262        let date_from = base_time - Duration::days(29);
3263        if let Some(CmdResult::TaggedTransactions {
3264            entities,
3265            pagination: Some(pagination),
3266        }) = ListTransactions::new()
3267            .user_id(user.id)
3268            .date_from(date_from)
3269            .limit(5)
3270            .run()
3271            .await?
3272        {
3273            assert_eq!(entities.len(), 5, "Expected 5 transactions with limit");
3274            assert_eq!(pagination.total_count, 30);
3275            assert!(pagination.has_more);
3276        } else {
3277            panic!("Expected TaggedTransactions result with pagination");
3278        }
3279    }
3280
3281    #[local_db_sqlx_test]
3282    async fn test_create_transaction_rejects_foreign_split_price(
3283        pool: PgPool,
3284    ) -> anyhow::Result<()> {
3285        let user = USER.get().unwrap();
3286        user.commit()
3287            .await
3288            .expect("Failed to commit user to database");
3289
3290        let commodity_result = CreateCommodity::new()
3291            .symbol("TST".to_string())
3292            .name("Test Commodity".to_string())
3293            .user_id(user.id)
3294            .run()
3295            .await?;
3296        let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
3297            uuid::Uuid::parse_str(&id)?
3298        } else {
3299            panic!("Expected commodity ID string result");
3300        };
3301
3302        let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3303            CreateAccount::new()
3304                .name("Account 1".to_string())
3305                .user_id(user.id)
3306                .run()
3307                .await?
3308        {
3309            account
3310        } else {
3311            panic!("Expected account entity result");
3312        };
3313        let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
3314            CreateAccount::new()
3315                .name("Account 2".to_string())
3316                .user_id(user.id)
3317                .run()
3318                .await?
3319        {
3320            account
3321        } else {
3322            panic!("Expected account entity result");
3323        };
3324
3325        let tx_id = Uuid::new_v4();
3326        let split1_id = Uuid::new_v4();
3327        let split2_id = Uuid::new_v4();
3328        let now = Utc::now();
3329        let split1 = Split {
3330            id: split1_id,
3331            tx_id,
3332            account_id: account1.id,
3333            commodity_id,
3334            value_num: -100,
3335            value_denom: 1,
3336            reconcile_state: None,
3337            reconcile_date: None,
3338            lot_id: None,
3339        };
3340        let split2 = Split {
3341            id: split2_id,
3342            tx_id,
3343            account_id: account2.id,
3344            commodity_id,
3345            value_num: 100,
3346            value_denom: 1,
3347            reconcile_state: None,
3348            reconcile_date: None,
3349            lot_id: None,
3350        };
3351
3352        // Price whose currency_split points at a split id NOT in this transaction.
3353        let foreign_split_id = Uuid::new_v4();
3354        let price = Price {
3355            id: Uuid::new_v4(),
3356            commodity_id,
3357            currency_id: commodity_id,
3358            commodity_split: Some(split1_id),
3359            currency_split: Some(foreign_split_id),
3360            date: now,
3361            value_num: 100,
3362            value_denom: 100,
3363        };
3364
3365        let result = CreateTransaction::new()
3366            .user_id(user.id)
3367            .splits(vec![
3368                FinanceEntity::Split(split1),
3369                FinanceEntity::Split(split2),
3370            ])
3371            .id(tx_id)
3372            .post_date(now)
3373            .enter_date(now)
3374            .prices(vec![FinanceEntity::Price(price)])
3375            .run()
3376            .await;
3377
3378        assert!(
3379            result.is_err(),
3380            "Expected error for price referencing a foreign split"
3381        );
3382
3383        let mut conn = user.get_connection().await?;
3384        let tx_count = sqlx::query!(
3385            "SELECT COUNT(*) as count FROM transactions WHERE id = $1",
3386            tx_id
3387        )
3388        .fetch_one(&mut *conn)
3389        .await?
3390        .count
3391        .unwrap_or(0);
3392        assert_eq!(
3393            tx_count, 0,
3394            "No transaction must persist on validation error"
3395        );
3396
3397        let split_count = sqlx::query!(
3398            "SELECT COUNT(*) as count FROM splits WHERE tx_id = $1",
3399            tx_id
3400        )
3401        .fetch_one(&mut *conn)
3402        .await?
3403        .count
3404        .unwrap_or(0);
3405        assert_eq!(split_count, 0, "No splits must persist on validation error");
3406    }
3407}
3408
3409#[cfg(test)]
3410mod aggregate_tests {
3411    use super::{SplitAmountRow, aggregate_split_amounts};
3412    use sqlx::types::Uuid;
3413
3414    /// Deterministic commodity id per symbol so same-symbol rows aggregate.
3415    fn commodity_for(symbol: &str) -> Uuid {
3416        let n = symbol.bytes().fold(0u128, |acc, b| {
3417            acc.wrapping_mul(31).wrapping_add(u128::from(b))
3418        });
3419        Uuid::from_u128(n | 1)
3420    }
3421
3422    fn row(tx_id: Uuid, num: i64, denom: i64, symbol: &str) -> SplitAmountRow {
3423        row_cid(tx_id, commodity_for(symbol), num, denom, symbol)
3424    }
3425
3426    fn row_cid(
3427        tx_id: Uuid,
3428        commodity_id: Uuid,
3429        num: i64,
3430        denom: i64,
3431        symbol: &str,
3432    ) -> SplitAmountRow {
3433        SplitAmountRow {
3434            tx_id,
3435            commodity_id,
3436            value_num: num,
3437            value_denom: denom,
3438            symbol: symbol.to_string(),
3439        }
3440    }
3441
3442    #[test]
3443    fn same_symbol_distinct_commodities_stay_separate() {
3444        let tx = Uuid::new_v4();
3445        let result = aggregate_split_amounts(vec![
3446            row_cid(tx, Uuid::from_u128(1), 100, 1, "USD"),
3447            row_cid(tx, Uuid::from_u128(2), 50, 1, "USD"),
3448        ]);
3449        assert_eq!(result.get(&tx).map(String::as_str), Some("100 USD; 50 USD"));
3450    }
3451
3452    #[test]
3453    fn empty_input_returns_empty_map() {
3454        assert!(aggregate_split_amounts(vec![]).is_empty());
3455    }
3456
3457    #[test]
3458    fn single_commodity_sums_correctly() {
3459        let id = Uuid::new_v4();
3460        let result = aggregate_split_amounts(vec![row(id, 50, 1, "USD"), row(id, 50, 1, "USD")]);
3461        assert_eq!(result.get(&id).map(String::as_str), Some("100 USD"));
3462    }
3463
3464    #[test]
3465    fn multi_commodity_formats_sorted() {
3466        let id = Uuid::new_v4();
3467        let result = aggregate_split_amounts(vec![row(id, 100, 1, "USD"), row(id, 50, 1, "EUR")]);
3468        assert_eq!(result.get(&id).map(String::as_str), Some("50 EUR; 100 USD"));
3469    }
3470
3471    #[test]
3472    fn fractional_amount_formatted() {
3473        let id = Uuid::new_v4();
3474        let result = aggregate_split_amounts(vec![row(id, 1, 3, "BTC")]);
3475        assert_eq!(result.get(&id).map(String::as_str), Some("1/3 BTC"));
3476    }
3477
3478    #[test]
3479    fn multiple_transactions_independent() {
3480        let id1 = Uuid::new_v4();
3481        let id2 = Uuid::new_v4();
3482        let result =
3483            aggregate_split_amounts(vec![row(id1, 100, 1, "USD"), row(id2, 200, 1, "EUR")]);
3484        assert_eq!(result.get(&id1).map(String::as_str), Some("100 USD"));
3485        assert_eq!(result.get(&id2).map(String::as_str), Some("200 EUR"));
3486    }
3487}