Skip to main content

finance/
transaction.rs

1use crate::error::{FinanceError, TransactionError};
2use crate::price::Price;
3use crate::split::Split;
4use crate::tag::Tag;
5use itertools::Itertools;
6use num_rational::Rational64;
7use sqlx::types::Uuid;
8use sqlx::types::chrono::{DateTime, Utc};
9use sqlx::{Connection, Postgres, query_file};
10use std::collections::HashMap;
11use supp_macro::Builder;
12
13/// The Transaction which is a start point of all the accounting.
14///
15/// A `Transaction` keeps id of a financial event and is used to group `Split`s
16/// using all the features provided.
17///
18/// # Attributes
19/// - `id`: A unique identifier for the transaction.
20/// - `commodity`: A reference to the `Commodity` associated of this transaction.
21/// - `num`: An optional number or code associated with the transaction.
22/// - `post_date`: The date the transaction is performed.
23/// - `enter_date`: The date the transaction is entered.
24/// - `description`: An optional description of the transaction.
25#[derive(Debug, sqlx::FromRow, Builder)]
26#[builder(error_kind = "FinanceError")]
27pub struct Transaction {
28    pub id: Uuid,
29    pub post_date: DateTime<Utc>,
30    pub enter_date: DateTime<Utc>,
31}
32
33/// An active ticket for a semi-entered transaction.
34///
35/// A `TransactionTicket` manages the lifecycle of the `Transaction`, allowing
36/// for explicit commit or rollback operations. It ensures that any changes
37/// made within the transaction are properly finalized or reverted.
38///
39/// # Generic Lifetimes
40/// - `'t`: Lifetime of the referenced `Transaction`.
41/// - `'s`: Lifetime of the `SQLx` transaction.
42pub struct TransactionTicket<'t, 's> {
43    sqltx: Option<sqlx::Transaction<'s, Postgres>>,
44    tx: &'t Transaction,
45}
46
47impl<'t> TransactionTicket<'t, '_> {
48    /// Validates the inputs and commits the transaction, finalizing all
49    /// changes.
50    ///
51    /// # Returns
52    /// A reference to the original `Transaction` if the commit is successful.
53    ///
54    /// # Errors
55    /// Returns a `FinanceError` if the ticket is empty or the commit operation fails.
56    pub async fn commit(&mut self) -> Result<&'t Transaction, FinanceError> {
57        if let Some(mut sqltx) = self.sqltx.take() {
58            let splits = query_file!("sql/transaction_select_splits.sql", &self.tx.id)
59                .fetch_all(&mut *sqltx)
60                .await?;
61
62            let distinct_commodities: Vec<Uuid> =
63                splits.iter().map(|s| s.commodity_id).unique().collect();
64
65            if distinct_commodities.is_empty() {
66                return Err(FinanceError::Internal(
67                    t!("No splits found for this transaction").to_string(),
68                ));
69            }
70
71            if distinct_commodities.len() == 1 {
72                let sum_splits = splits
73                    .iter()
74                    .map(|s| Rational64::new(s.value_num, s.value_denom))
75                    .reduce(|a, b| a + b)
76                    .ok_or_else(|| FinanceError::Internal(t!("Erroneous split").to_string()))?;
77
78                if sum_splits != 0.into() {
79                    return Err(FinanceError::Transaction(TransactionError::Build(
80                        t!("Unbalanced Transaction: sum of splits is non-zero").to_string(),
81                    )));
82                }
83            } else {
84                // Multi-currency transaction. Just pick the first commodity as
85                // the base for computations.
86                let base_commodity = distinct_commodities[0];
87
88                // Group splits by commodity to "base vs. price"
89                let mut splits_by_commodity: HashMap<Uuid, Vec<_>> = HashMap::new();
90                for s in &splits {
91                    splits_by_commodity
92                        .entry(s.commodity_id)
93                        .or_default()
94                        .push(s);
95                }
96
97                let mut transaction_sum = Rational64::from_integer(0);
98
99                // Sum the "base"
100                if let Some(base_splits) = splits_by_commodity.get(&base_commodity) {
101                    for s in base_splits {
102                        transaction_sum += Rational64::new(s.value_num, s.value_denom);
103                    }
104                } else {
105                    return Err(FinanceError::Internal(format!(
106                        "{}",
107                        t!("Internal error: multi-currency transaction is inconsistent")
108                    )));
109                }
110
111                // Sum the rest with currency conversion
112                for (commodity_id, split_group) in &splits_by_commodity {
113                    if *commodity_id == base_commodity {
114                        // Already handled
115                        continue;
116                    }
117
118                    let split_ids: Vec<Uuid> = split_group.iter().map(|s| s.id).collect();
119                    let prices =
120                        query_file!("sql/transaction_select_prices_by_splits.sql", &split_ids)
121                            .fetch_all(&mut *sqltx)
122                            .await?;
123
124                    if prices.is_empty() {
125                        return Err(FinanceError::Internal(format!(
126                            "{} {}",
127                            t!("No price records found for commodity: "),
128                            commodity_id
129                        )));
130                    }
131
132                    // Build a map: split_id -> Vec<price_record>
133                    let mut price_map: HashMap<Uuid, Vec<_>> = HashMap::new();
134                    for p in prices {
135                        // Add both commodity...
136                        if let Some(cid) = p.commodity_split_id {
137                            price_map.entry(cid).or_default().push(p);
138                        } else if let Some(cid) = p.currency_split_id {
139                            // ... and currency
140                            price_map.entry(cid).or_default().push(p);
141                        }
142                    }
143
144                    for s in split_group {
145                        let split_val = Rational64::new(s.value_num, s.value_denom);
146
147                        let split_price = price_map.get(&s.id).ok_or_else(|| {
148                            FinanceError::Internal(format!(
149                                "{} {}",
150                                t!("Price not found for split"),
151                                s.id
152                            ))
153                        })?;
154
155                        let price = split_price.first().ok_or_else(|| {
156                            FinanceError::Internal(format!(
157                                "{} {}",
158                                t!("Price not found for split"),
159                                s.id
160                            ))
161                        })?;
162
163                        let conv_rate = Rational64::new(price.value_num, price.value_denom);
164
165                        let sum_converted = if price.commodity_split_id == Some(s.id) {
166                            // "Base" currency matched the currency
167                            split_val * conv_rate
168                        } else {
169                            // "Base" currency is commodity, invert
170                            split_val * conv_rate.recip()
171                        };
172
173                        transaction_sum += sum_converted;
174                    }
175                }
176
177                if transaction_sum != 0.into() {
178                    return Err(FinanceError::Transaction(TransactionError::Build(
179                        t!("Unbalanced Transaction after conversion: sum != 0").to_string(),
180                    )));
181                }
182            }
183
184            sqltx.commit().await?;
185            Ok(self.tx)
186        } else {
187            Err(FinanceError::Internal(
188                t!("Attempt to commit the empty ticket").to_string(),
189            ))
190        }
191    }
192
193    /// Rolls back the transaction, reverting all changes.
194    ///
195    /// # Returns
196    /// A reference to the original `Transaction` if the rollback is successful.
197    ///
198    /// # Errors
199    /// Returns a `FinanceError` if the ticket is empty or the rollback operation fails.
200    pub async fn rollback(&mut self) -> Result<&'t Transaction, FinanceError> {
201        if let Some(sqltx) = self.sqltx.take() {
202            sqltx.rollback().await?;
203            Ok(self.tx)
204        } else {
205            Err(FinanceError::Internal(
206                t!("Attempt to rollback the empty ticket").to_string(),
207            ))
208        }
209    }
210
211    pub async fn add_splits(&mut self, splits: &[&Split]) -> Result<&'t Transaction, FinanceError> {
212        if let Some(sqltx) = &mut self.sqltx {
213            for s in splits {
214                if s.tx_id != self.tx.id {
215                    return Err(FinanceError::Transaction(TransactionError::WrongSplit(
216                        t!("Attempt to apply split from another transaction").to_string(),
217                    )));
218                }
219                query_file!(
220                    "sql/split_insert.sql",
221                    &s.id,
222                    &s.tx_id,
223                    &s.account_id,
224                    &s.commodity_id,
225                    s.reconcile_state,
226                    s.reconcile_date,
227                    &s.value_num,
228                    &s.value_denom,
229                    s.lot_id
230                )
231                .execute(&mut **sqltx)
232                .await?;
233            }
234            Ok(self.tx)
235        } else {
236            Err(FinanceError::Internal(
237                t!("Adding splits failed").to_string(),
238            ))
239        }
240    }
241
242    pub async fn add_conversions(
243        &mut self,
244        prices: &[&Price],
245    ) -> Result<&'t Transaction, FinanceError> {
246        if let Some(sqltx) = &mut self.sqltx {
247            for p in prices {
248                p.commit(&mut **sqltx).await?;
249            }
250            Ok(self.tx)
251        } else {
252            Err(FinanceError::Internal(
253                t!("Adding conversions failed").to_string(),
254            ))
255        }
256    }
257
258    pub async fn add_tags(&mut self, tags: &[&Tag]) -> Result<&'t Transaction, FinanceError> {
259        if let Some(sqltx) = &mut self.sqltx {
260            for t in tags {
261                let tag_id = t.commit(&mut **sqltx).await?;
262                query_file!("sql/transaction_tag_set.sql", self.tx.id, tag_id)
263                    .execute(&mut **sqltx)
264                    .await?;
265            }
266            Ok(self.tx)
267        } else {
268            Err(FinanceError::Internal(t!("Adding tags failed").to_string()))
269        }
270    }
271
272    pub async fn add_split_tags(
273        &mut self,
274        split_tags: &[(Uuid, Tag)],
275    ) -> Result<&'t Transaction, FinanceError> {
276        if let Some(sqltx) = &mut self.sqltx {
277            for (split_id, tag) in split_tags {
278                let tag_id = tag.commit(&mut **sqltx).await?;
279                query_file!("sql/split_tag_set.sql", split_id, tag_id)
280                    .execute(&mut **sqltx)
281                    .await?;
282            }
283            Ok(self.tx)
284        } else {
285            Err(FinanceError::Internal(
286                t!("Adding split tags failed").to_string(),
287            ))
288        }
289    }
290
291    /// Execute an arbitrary async operation within this ticket's DB transaction.
292    ///
293    /// This enables custom operations (like adding split tags) without adding
294    /// specific methods for each entity type.
295    pub async fn execute<F, Fut, T>(&mut self, f: F) -> Result<T, FinanceError>
296    where
297        F: FnOnce(&mut sqlx::PgConnection) -> Fut,
298        Fut: std::future::Future<Output = Result<T, sqlx::Error>>,
299    {
300        if let Some(sqltx) = &mut self.sqltx {
301            f(sqltx).await.map_err(Into::into)
302        } else {
303            Err(FinanceError::Internal(
304                t!("Cannot execute on empty ticket").to_string(),
305            ))
306        }
307    }
308}
309
310impl<'t> Transaction {
311    /// Inserts the transaction into the database and starts data input.
312    ///
313    /// This method begins a new database transaction, inserts the transaction
314    /// details into the DB, and returns a `TransactionTicket` to manage the
315    /// rest of data (`Split`s, `Tag`s, and so on). The DB transaction must be
316    /// committed (or rolled back) from via the `TransactionTicket` returned.
317    ///
318    /// # Parameters
319    /// - `conn`: A connection to the database.
320    ///
321    /// # Returns
322    /// A `TransactionTicket` for managing the transaction.
323    ///
324    /// # Errors
325    /// Returns a `FinanceError` if the database operation fails.
326    pub async fn enter<'p, E>(
327        &'t self,
328        conn: &'p mut E,
329    ) -> Result<TransactionTicket<'t, 'p>, FinanceError>
330    where
331        E: Connection<Database = sqlx::Postgres>,
332    {
333        let mut tr = conn.begin().await?;
334
335        sqlx::query_file!(
336            "sql/transaction_insert.sql",
337            &self.id,
338            &self.post_date,
339            &self.enter_date
340        )
341        .execute(&mut *tr)
342        .await?;
343
344        Ok(TransactionTicket {
345            sqltx: Some(tr),
346            tx: self,
347        })
348    }
349}
350
351#[cfg(test)]
352mod transaction_tests {
353    use super::*;
354    use crate::account::{Account, AccountBuilder};
355    use crate::commodity::{Commodity, CommodityBuilder};
356    use crate::split::SplitBuilder;
357    #[cfg(feature = "testlog")]
358    use env_logger;
359    #[cfg(feature = "testlog")]
360    use log;
361    use sqlx::PgPool;
362    use sqlx::types::chrono::Local;
363    use tokio::sync::OnceCell;
364
365    /// Context for keeping environment intact
366    static CONTEXT: OnceCell<()> = OnceCell::const_new();
367    static COMMODITY: OnceCell<Commodity> = OnceCell::const_new();
368    static WALLET: OnceCell<Account> = OnceCell::const_new();
369    static SHOP: OnceCell<Account> = OnceCell::const_new();
370
371    static FOREIGN_COMMODITY: OnceCell<Commodity> = OnceCell::const_new();
372    static ONLINE_SHOP: OnceCell<Account> = OnceCell::const_new();
373
374    static FOREIGN_COMMODITY_2: OnceCell<Commodity> = OnceCell::const_new();
375    static ONLINE_SHOP_2: OnceCell<Account> = OnceCell::const_new();
376
377    async fn setup(pool: &PgPool) {
378        let mut conn = pool.acquire().await.unwrap();
379
380        CONTEXT
381            .get_or_init(|| async {
382                #[cfg(feature = "testlog")]
383                let _ = env_logger::builder()
384                    .is_test(true)
385                    .filter_level(log::LevelFilter::Trace)
386                    .try_init();
387            })
388            .await;
389
390        COMMODITY
391            .get_or_init(|| async { CommodityBuilder::new().id(Uuid::new_v4()).build().unwrap() })
392            .await;
393        COMMODITY.get().unwrap().commit(&mut *conn).await.unwrap();
394
395        WALLET
396            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
397            .await;
398        WALLET.get().unwrap().commit(&mut *conn).await.unwrap();
399
400        SHOP.get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
401            .await;
402        SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
403
404        FOREIGN_COMMODITY
405            .get_or_init(|| async { CommodityBuilder::new().id(Uuid::new_v4()).build().unwrap() })
406            .await;
407        FOREIGN_COMMODITY
408            .get()
409            .unwrap()
410            .commit(&mut *conn)
411            .await
412            .unwrap();
413
414        ONLINE_SHOP
415            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
416            .await;
417        ONLINE_SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
418
419        FOREIGN_COMMODITY_2
420            .get_or_init(|| async { CommodityBuilder::new().id(Uuid::new_v4()).build().unwrap() })
421            .await;
422        FOREIGN_COMMODITY_2
423            .get()
424            .unwrap()
425            .commit(&mut *conn)
426            .await
427            .unwrap();
428
429        ONLINE_SHOP_2
430            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
431            .await;
432        ONLINE_SHOP_2
433            .get()
434            .unwrap()
435            .commit(&mut *conn)
436            .await
437            .unwrap();
438    }
439
440    #[sqlx::test(migrations = "../migrations")]
441    async fn test_transaction_store(pool: PgPool) {
442        setup(&pool).await;
443        let mut conn = pool.acquire().await.unwrap();
444
445        let transaction = Transaction {
446            id: Uuid::new_v4(),
447            post_date: Local::now().into(),
448            enter_date: Local::now().into(),
449        };
450
451        sqlx::query_file!(
452            "sql/transaction_insert.sql",
453            &transaction.id,
454            &transaction.post_date,
455            &transaction.enter_date
456        )
457        .execute(&mut *conn)
458        .await
459        .unwrap();
460
461        let result = sqlx::query!("SELECT id, post_date FROM transactions")
462            .fetch_one(&mut *conn)
463            .await
464            .unwrap();
465
466        assert_eq!(transaction.id, result.id);
467    }
468
469    #[sqlx::test(migrations = "../migrations")]
470    async fn test_transaction_builer(pool: PgPool) -> anyhow::Result<()> {
471        setup(&pool).await;
472        let build = Transaction::builder().id(Uuid::new_v4()).build();
473        assert!(build.is_err());
474
475        let build = Transaction::builder()
476            .id(Uuid::new_v4())
477            .post_date(Local::now().into())
478            .enter_date(Local::now().into())
479            .build();
480        assert!(build.is_ok());
481
482        Ok(())
483    }
484
485    #[sqlx::test(migrations = "../migrations")]
486    async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
487        setup(&pool).await;
488
489        let tx = Transaction::builder()
490            .id(Uuid::new_v4())
491            .post_date(Local::now().into())
492            .enter_date(Local::now().into())
493            .build()?;
494
495        let mut conn = pool.acquire().await?;
496        let mut tr = tx.enter(&mut *conn).await?;
497        tr.rollback().await?;
498        let mut conn = pool.acquire().await?;
499        assert!(
500            sqlx::query!("SELECT id, post_date FROM transactions")
501                .fetch_one(&mut *conn)
502                .await
503                .is_err()
504        );
505
506        let mut conn = pool.acquire().await?;
507        let mut tr = tx.enter(&mut *conn).await?;
508        assert!(tr.commit().await.is_err()); // Erroneous split
509
510        Ok(())
511    }
512
513    /// Regression: two transactions tagged with the same
514    /// `(tag_name, tag_value)` pair must both link to the canonical
515    /// row. Before the fix `add_tags` linked via the in-memory
516    /// `Tag.id`, so the second call tripped
517    /// `transaction_tags_tag_id_fkey` because the canonical row's
518    /// id was returned by `Tag::commit` but discarded. Same shape
519    /// for `add_split_tags` — covered as well.
520    #[sqlx::test(migrations = "../migrations")]
521    async fn test_add_tags_uses_canonical_id(pool: PgPool) -> anyhow::Result<()> {
522        setup(&pool).await;
523
524        let tag_template = || Tag {
525            id: Uuid::new_v4(),
526            tag_name: "category".to_string(),
527            tag_value: "groceries".to_string(),
528            description: None,
529        };
530
531        for value in [-100i64, -200] {
532            let tx = Transaction::builder()
533                .id(Uuid::new_v4())
534                .post_date(Local::now().into())
535                .enter_date(Local::now().into())
536                .build()?;
537
538            let mut conn = pool.acquire().await?;
539            let mut tr = tx.enter(&mut *conn).await?;
540            let split_spend = SplitBuilder::new()
541                .account_id(WALLET.get().unwrap().id)
542                .commodity_id(COMMODITY.get().unwrap().id)
543                .id(Uuid::new_v4())
544                .value_num(value)
545                .value_denom(1)
546                .tx_id(tx.id)
547                .build()?;
548            let split_purchase = SplitBuilder::new()
549                .account_id(SHOP.get().unwrap().id)
550                .commodity_id(COMMODITY.get().unwrap().id)
551                .id(Uuid::new_v4())
552                .value_num(-value)
553                .value_denom(1)
554                .tx_id(tx.id)
555                .build()?;
556            tr.add_splits(&[&split_spend, &split_purchase]).await?;
557
558            let tx_tag = tag_template();
559            tr.add_tags(&[&tx_tag]).await?;
560            tr.add_split_tags(&[(split_spend.id, tag_template())])
561                .await?;
562            tr.commit().await?;
563        }
564
565        let mut conn = pool.acquire().await?;
566        let tag_count: i64 =
567            sqlx::query_scalar("SELECT COUNT(*) FROM tags WHERE tag_name = 'category'")
568                .fetch_one(&mut *conn)
569                .await?;
570        assert_eq!(tag_count, 1, "tag rows must be canonical");
571        let link_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transaction_tags")
572            .fetch_one(&mut *conn)
573            .await?;
574        assert_eq!(
575            link_count, 2,
576            "both transactions linked to the canonical tag"
577        );
578        let split_link_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM split_tags")
579            .fetch_one(&mut *conn)
580            .await?;
581        assert_eq!(
582            split_link_count, 2,
583            "both splits linked to the canonical tag"
584        );
585
586        Ok(())
587    }
588
589    #[sqlx::test(migrations = "../migrations")]
590    async fn test_transaction_balance(pool: PgPool) -> anyhow::Result<()> {
591        setup(&pool).await;
592
593        let tx = Transaction::builder()
594            .id(Uuid::new_v4())
595            .post_date(Local::now().into())
596            .enter_date(Local::now().into())
597            .build()?;
598
599        let mut conn = pool.acquire().await?;
600        let mut tr = tx.enter(&mut *conn).await?;
601        let split_spend = SplitBuilder::new()
602            .account_id(WALLET.get().unwrap().id)
603            .commodity_id(COMMODITY.get().unwrap().id)
604            .id(Uuid::new_v4())
605            .value_num(-100)
606            .value_denom(1)
607            .tx_id(tx.id)
608            .build()?;
609
610        let split_purchase = SplitBuilder::new()
611            .account_id(SHOP.get().unwrap().id)
612            .commodity_id(COMMODITY.get().unwrap().id)
613            .id(Uuid::new_v4())
614            .value_num(100)
615            .value_denom(1)
616            .tx_id(tx.id)
617            .build()?;
618
619        tr.add_splits(&[&split_spend, &split_purchase]).await?;
620        assert!(tr.commit().await.is_ok());
621
622        let tx = Transaction::builder()
623            .id(Uuid::new_v4())
624            .post_date(Local::now().into())
625            .enter_date(Local::now().into())
626            .build()?;
627
628        let mut conn = pool.acquire().await?;
629        let mut tr = tx.enter(&mut *conn).await?;
630        let split_spend = SplitBuilder::new()
631            .account_id(WALLET.get().unwrap().id)
632            .commodity_id(COMMODITY.get().unwrap().id)
633            .id(Uuid::new_v4())
634            .value_num(-100)
635            .value_denom(1)
636            .tx_id(tx.id)
637            .build()?;
638
639        let split_purchase = SplitBuilder::new()
640            .account_id(SHOP.get().unwrap().id)
641            .commodity_id(COMMODITY.get().unwrap().id)
642            .id(Uuid::new_v4())
643            .value_num(99)
644            .value_denom(1)
645            .tx_id(tx.id)
646            .build()?;
647
648        tr.add_splits(&[&split_spend, &split_purchase]).await?;
649        assert!(tr.commit().await.is_err());
650
651        Ok(())
652    }
653
654    #[sqlx::test(migrations = "../migrations")]
655    async fn test_transaction_multicurrency(pool: PgPool) -> anyhow::Result<()> {
656        setup(&pool).await;
657
658        let tx = Transaction::builder()
659            .id(Uuid::new_v4())
660            .post_date(Local::now().into())
661            .enter_date(Local::now().into())
662            .build()?;
663
664        let mut conn = pool.acquire().await?;
665        let mut tr = tx.enter(&mut *conn).await?;
666        let split_spend = SplitBuilder::new()
667            .account_id(WALLET.get().unwrap().id)
668            .commodity_id(COMMODITY.get().unwrap().id)
669            .id(Uuid::new_v4())
670            .value_num(-1000)
671            .value_denom(1)
672            .tx_id(tx.id)
673            .build()?;
674
675        let split_purchase = SplitBuilder::new()
676            .account_id(ONLINE_SHOP.get().unwrap().id)
677            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
678            .id(Uuid::new_v4())
679            .value_num(7)
680            .value_denom(1)
681            .tx_id(tx.id)
682            .build()?;
683
684        tr.add_splits(&[&split_spend, &split_purchase]).await?;
685
686        let conversion = Price {
687            id: Uuid::new_v4(),
688            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
689            currency_id: COMMODITY.get().unwrap().id,
690            commodity_split: Some(split_purchase.id),
691            currency_split: Some(split_spend.id),
692            date: Local::now().into(),
693            value_num: 1000,
694            value_denom: 7,
695        };
696
697        tr.add_conversions(&[&conversion]).await?;
698
699        assert!(tr.commit().await.is_ok());
700
701        Ok(())
702    }
703
704    #[sqlx::test(migrations = "../migrations")]
705    async fn test_transaction_multicurrency_fail(pool: PgPool) -> anyhow::Result<()> {
706        setup(&pool).await;
707
708        let tx = Transaction::builder()
709            .id(Uuid::new_v4())
710            .post_date(Local::now().into())
711            .enter_date(Local::now().into())
712            .build()?;
713
714        let mut conn = pool.acquire().await?;
715        let mut tr = tx.enter(&mut *conn).await?;
716
717        let split_spend = SplitBuilder::new()
718            .account_id(WALLET.get().unwrap().id)
719            .commodity_id(COMMODITY.get().unwrap().id)
720            .id(Uuid::new_v4())
721            .value_num(-1000)
722            .value_denom(1)
723            .tx_id(tx.id)
724            .build()?;
725
726        let split_purchase = SplitBuilder::new()
727            .account_id(ONLINE_SHOP.get().unwrap().id)
728            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
729            .id(Uuid::new_v4())
730            .value_num(7)
731            .value_denom(1)
732            .tx_id(tx.id)
733            .build()?;
734
735        let split_spend_2 = SplitBuilder::new()
736            .account_id(WALLET.get().unwrap().id)
737            .commodity_id(COMMODITY.get().unwrap().id)
738            .id(Uuid::new_v4())
739            .value_num(-1000)
740            .value_denom(1)
741            .tx_id(tx.id)
742            .build()?;
743
744        let split_purchase_2 = SplitBuilder::new()
745            .account_id(ONLINE_SHOP_2.get().unwrap().id)
746            .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
747            .id(Uuid::new_v4())
748            .value_num(10)
749            .value_denom(1)
750            .tx_id(tx.id)
751            .build()?;
752
753        tr.add_splits(&[
754            &split_spend,
755            &split_purchase,
756            &split_spend_2,
757            &split_purchase_2,
758        ])
759        .await?;
760
761        let conversion = Price {
762            id: Uuid::new_v4(),
763            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
764            currency_id: COMMODITY.get().unwrap().id,
765            commodity_split: Some(split_purchase.id),
766            currency_split: Some(split_spend.id),
767            date: Local::now().into(),
768            value_num: 1000,
769            value_denom: 7,
770        };
771
772        tr.add_conversions(&[&conversion]).await?;
773
774        assert!(tr.commit().await.is_err()); // No second conversion
775
776        Ok(())
777    }
778
779    #[sqlx::test(migrations = "../migrations")]
780    async fn test_transaction_multicurrency_2(pool: PgPool) -> anyhow::Result<()> {
781        setup(&pool).await;
782
783        let tx = Transaction::builder()
784            .id(Uuid::new_v4())
785            .post_date(Local::now().into())
786            .enter_date(Local::now().into())
787            .build()?;
788
789        let mut conn = pool.acquire().await?;
790        let mut tr = tx.enter(&mut *conn).await?;
791
792        let split_spend = SplitBuilder::new()
793            .account_id(WALLET.get().unwrap().id)
794            .commodity_id(COMMODITY.get().unwrap().id)
795            .id(Uuid::new_v4())
796            .value_num(-1000)
797            .value_denom(1)
798            .tx_id(tx.id)
799            .build()?;
800
801        let split_purchase = SplitBuilder::new()
802            .account_id(ONLINE_SHOP.get().unwrap().id)
803            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
804            .id(Uuid::new_v4())
805            .value_num(7)
806            .value_denom(1)
807            .tx_id(tx.id)
808            .build()?;
809
810        let split_spend_2 = SplitBuilder::new()
811            .account_id(WALLET.get().unwrap().id)
812            .commodity_id(COMMODITY.get().unwrap().id)
813            .id(Uuid::new_v4())
814            .value_num(-1000)
815            .value_denom(1)
816            .tx_id(tx.id)
817            .build()?;
818
819        let split_purchase_2 = SplitBuilder::new()
820            .account_id(ONLINE_SHOP_2.get().unwrap().id)
821            .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
822            .id(Uuid::new_v4())
823            .value_num(10)
824            .value_denom(1)
825            .tx_id(tx.id)
826            .build()?;
827
828        tr.add_splits(&[
829            &split_spend,
830            &split_purchase,
831            &split_spend_2,
832            &split_purchase_2,
833        ])
834        .await?;
835
836        let conversion = Price {
837            id: Uuid::new_v4(),
838            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
839            currency_id: COMMODITY.get().unwrap().id,
840            commodity_split: Some(split_purchase.id),
841            currency_split: Some(split_spend.id),
842            date: Local::now().into(),
843            value_num: 1000,
844            value_denom: 7,
845        };
846
847        let conversion_2 = Price {
848            id: Uuid::new_v4(),
849            commodity_id: FOREIGN_COMMODITY_2.get().unwrap().id,
850            currency_id: COMMODITY.get().unwrap().id,
851            commodity_split: Some(split_purchase_2.id),
852            currency_split: Some(split_spend_2.id),
853            date: Local::now().into(),
854            value_num: 1000,
855            value_denom: 10,
856        };
857
858        tr.add_conversions(&[&conversion, &conversion_2]).await?;
859
860        assert!(tr.commit().await.is_ok());
861
862        Ok(())
863    }
864
865    #[sqlx::test(migrations = "../migrations")]
866    async fn test_transaction_unbalanced(pool: PgPool) -> anyhow::Result<()> {
867        setup(&pool).await;
868
869        let tx = Transaction::builder()
870            .id(Uuid::new_v4())
871            .post_date(Local::now().into())
872            .enter_date(Local::now().into())
873            .build()?;
874
875        let mut conn = pool.acquire().await?;
876        let mut tr = tx.enter(&mut *conn).await?;
877        let split_spend = SplitBuilder::new()
878            .account_id(WALLET.get().unwrap().id)
879            .commodity_id(COMMODITY.get().unwrap().id)
880            .id(Uuid::new_v4())
881            .value_num(-100)
882            .value_denom(1)
883            .tx_id(tx.id)
884            .build()?;
885
886        let split_purchase = SplitBuilder::new()
887            .account_id(SHOP.get().unwrap().id)
888            .commodity_id(COMMODITY.get().unwrap().id)
889            .id(Uuid::new_v4())
890            .value_num(90)
891            .value_denom(1)
892            .tx_id(tx.id)
893            .build()?;
894
895        tr.add_splits(&[&split_spend, &split_purchase]).await?;
896        assert!(tr.commit().await.is_err());
897
898        Ok(())
899    }
900
901    #[sqlx::test(migrations = "../migrations")]
902    async fn test_transaction_multicurrency_jpy_usd(pool: PgPool) -> anyhow::Result<()> {
903        setup(&pool).await;
904
905        let tx = Transaction::builder()
906            .id(Uuid::new_v4())
907            .post_date(Local::now().into())
908            .enter_date(Local::now().into())
909            .build()?;
910
911        let mut conn = pool.acquire().await?;
912        let mut tr = tx.enter(&mut *conn).await?;
913
914        // Spend 25584 JPY (denom=1 since JPY has no fractional part)
915        let split_spend = SplitBuilder::new()
916            .account_id(WALLET.get().unwrap().id)
917            .commodity_id(COMMODITY.get().unwrap().id)
918            .id(Uuid::new_v4())
919            .value_num(-25584)
920            .value_denom(1)
921            .tx_id(tx.id)
922            .build()?;
923
924        // Receive 153.81 USD = 15381/100
925        let split_receive = SplitBuilder::new()
926            .account_id(ONLINE_SHOP.get().unwrap().id)
927            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
928            .id(Uuid::new_v4())
929            .value_num(15381)
930            .value_denom(100)
931            .tx_id(tx.id)
932            .build()?;
933
934        tr.add_splits(&[&split_spend, &split_receive]).await?;
935
936        // Rate: 1 USD = 25584/153.81 JPY = 2558400/15381 JPY
937        // Verification: 15381/100 * 2558400/15381 = 2558400/100 = 25584
938        // So transaction_sum = -25584 + 25584 = 0
939        let conversion = Price {
940            id: Uuid::new_v4(),
941            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
942            currency_id: COMMODITY.get().unwrap().id,
943            commodity_split: Some(split_receive.id),
944            currency_split: Some(split_spend.id),
945            date: Local::now().into(),
946            value_num: 2558400,
947            value_denom: 15381,
948        };
949
950        tr.add_conversions(&[&conversion]).await?;
951
952        assert!(tr.commit().await.is_ok());
953
954        Ok(())
955    }
956}