1
use crate::error::{FinanceError, TransactionError};
2
use crate::price::Price;
3
use crate::split::Split;
4
use crate::tag::Tag;
5
use itertools::Itertools;
6
use num_rational::Rational64;
7
use sqlx::types::Uuid;
8
use sqlx::types::chrono::{DateTime, Utc};
9
use sqlx::{Connection, Postgres, query_file};
10
use std::collections::HashMap;
11
use 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")]
27
pub 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.
42
pub struct TransactionTicket<'t, 's> {
43
    sqltx: Option<sqlx::Transaction<'s, Postgres>>,
44
    tx: &'t Transaction,
45
}
46

            
47
impl<'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
277
    pub async fn commit(&mut self) -> Result<&'t Transaction, FinanceError> {
57
78
        if let Some(mut sqltx) = self.sqltx.take() {
58
78
            let splits = query_file!("sql/transaction_select_splits.sql", &self.tx.id)
59
78
                .fetch_all(&mut *sqltx)
60
78
                .await?;
61

            
62
78
            let distinct_commodities: Vec<Uuid> =
63
78
                splits.iter().map(|s| s.commodity_id).unique().collect();
64

            
65
78
            if distinct_commodities.is_empty() {
66
                return Err(FinanceError::Internal(
67
2
                    t!("No splits found for this transaction").to_string(),
68
                ));
69
76
            }
70

            
71
76
            if distinct_commodities.len() == 1 {
72
70
                let sum_splits = splits
73
70
                    .iter()
74
140
                    .map(|s| Rational64::new(s.value_num, s.value_denom))
75
70
                    .reduce(|a, b| a + b)
76
70
                    .ok_or_else(|| FinanceError::Internal(t!("Erroneous split").to_string()))?;
77

            
78
70
                if sum_splits != 0.into() {
79
                    return Err(FinanceError::Transaction(TransactionError::Build(
80
4
                        t!("Unbalanced Transaction: sum of splits is non-zero").to_string(),
81
                    )));
82
66
                }
83
            } else {
84
                // Multi-currency transaction. Just pick the first commodity as
85
                // the base for computations.
86
6
                let base_commodity = distinct_commodities[0];
87

            
88
                // Group splits by commodity to "base vs. price"
89
6
                let mut splits_by_commodity: HashMap<Uuid, Vec<_>> = HashMap::new();
90
26
                for s in &splits {
91
20
                    splits_by_commodity
92
20
                        .entry(s.commodity_id)
93
20
                        .or_default()
94
20
                        .push(s);
95
20
                }
96

            
97
6
                let mut transaction_sum = Rational64::from_integer(0);
98

            
99
                // Sum the "base"
100
6
                if let Some(base_splits) = splits_by_commodity.get(&base_commodity) {
101
16
                    for s in base_splits {
102
10
                        transaction_sum += Rational64::new(s.value_num, s.value_denom);
103
10
                    }
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
18
                for (commodity_id, split_group) in &splits_by_commodity {
113
14
                    if *commodity_id == base_commodity {
114
                        // Already handled
115
4
                        continue;
116
10
                    }
117

            
118
10
                    let split_ids: Vec<Uuid> = split_group.iter().map(|s| s.id).collect();
119
10
                    let prices =
120
10
                        query_file!("sql/transaction_select_prices_by_splits.sql", &split_ids)
121
10
                            .fetch_all(&mut *sqltx)
122
10
                            .await?;
123

            
124
10
                    if prices.is_empty() {
125
2
                        return Err(FinanceError::Internal(format!(
126
2
                            "{} {}",
127
2
                            t!("No price records found for commodity: "),
128
                            commodity_id
129
                        )));
130
8
                    }
131

            
132
                    // Build a map: split_id -> Vec<price_record>
133
8
                    let mut price_map: HashMap<Uuid, Vec<_>> = HashMap::new();
134
16
                    for p in prices {
135
                        // Add both commodity...
136
8
                        if let Some(cid) = p.commodity_split_id {
137
8
                            price_map.entry(cid).or_default().push(p);
138
8
                        } 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
16
                    for s in split_group {
145
8
                        let split_val = Rational64::new(s.value_num, s.value_denom);
146

            
147
8
                        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
8
                        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
8
                        let conv_rate = Rational64::new(price.value_num, price.value_denom);
164

            
165
8
                        let sum_converted = if price.commodity_split_id == Some(s.id) {
166
                            // "Base" currency matched the currency
167
8
                            split_val * conv_rate
168
                        } else {
169
                            // "Base" currency is commodity, invert
170
                            split_val * conv_rate.recip()
171
                        };
172

            
173
8
                        transaction_sum += sum_converted;
174
                    }
175
                }
176

            
177
4
                if transaction_sum != 0.into() {
178
                    return Err(FinanceError::Transaction(TransactionError::Build(
179
                        t!("Unbalanced Transaction after conversion: sum != 0").to_string(),
180
                    )));
181
4
                }
182
            }
183

            
184
70
            sqltx.commit().await?;
185
70
            Ok(self.tx)
186
        } else {
187
            Err(FinanceError::Internal(
188
                t!("Attempt to commit the empty ticket").to_string(),
189
            ))
190
        }
191
78
    }
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
3
    pub async fn rollback(&mut self) -> Result<&'t Transaction, FinanceError> {
201
2
        if let Some(sqltx) = self.sqltx.take() {
202
2
            sqltx.rollback().await?;
203
2
            Ok(self.tx)
204
        } else {
205
            Err(FinanceError::Internal(
206
                t!("Attempt to rollback the empty ticket").to_string(),
207
            ))
208
        }
209
2
    }
210

            
211
274
    pub async fn add_splits(&mut self, splits: &[&Split]) -> Result<&'t Transaction, FinanceError> {
212
76
        if let Some(sqltx) = &mut self.sqltx {
213
236
            for s in splits {
214
160
                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
160
                }
219
160
                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
160
                .execute(&mut **sqltx)
232
160
                .await?;
233
            }
234
76
            Ok(self.tx)
235
        } else {
236
            Err(FinanceError::Internal(
237
                t!("Adding splits failed").to_string(),
238
            ))
239
        }
240
76
    }
241

            
242
13
    pub async fn add_conversions(
243
13
        &mut self,
244
13
        prices: &[&Price],
245
17
    ) -> Result<&'t Transaction, FinanceError> {
246
8
        if let Some(sqltx) = &mut self.sqltx {
247
18
            for p in prices {
248
10
                p.commit(&mut **sqltx).await?;
249
            }
250
8
            Ok(self.tx)
251
        } else {
252
            Err(FinanceError::Internal(
253
                t!("Adding conversions failed").to_string(),
254
            ))
255
        }
256
8
    }
257

            
258
88
    pub async fn add_tags(&mut self, tags: &[&Tag]) -> Result<&'t Transaction, FinanceError> {
259
22
        if let Some(sqltx) = &mut self.sqltx {
260
44
            for t in tags {
261
22
                t.commit(&mut **sqltx).await?;
262
22
                query_file!("sql/transaction_tag_set.sql", self.tx.id, t.id,)
263
22
                    .execute(&mut **sqltx)
264
22
                    .await?;
265
            }
266
22
            Ok(self.tx)
267
        } else {
268
            Err(FinanceError::Internal(t!("Adding tags failed").to_string()))
269
        }
270
22
    }
271
}
272

            
273
impl<'t> Transaction {
274
    /// Inserts the transaction into the database and starts data input.
275
    ///
276
    /// This method begins a new database transaction, inserts the transaction
277
    /// details into the DB, and returns a `TransactionTicket` to manage the
278
    /// rest of data (`Split`s, `Tag`s, and so on). The DB transaction must be
279
    /// committed (or rolled back) from via the `TransactionTicket` returned.
280
    ///
281
    /// # Parameters
282
    /// - `conn`: A connection to the database.
283
    ///
284
    /// # Returns
285
    /// A `TransactionTicket` for managing the transaction.
286
    ///
287
    /// # Errors
288
    /// Returns a `FinanceError` if the database operation fails.
289
80
    pub async fn enter<'p, E>(
290
80
        &'t self,
291
80
        conn: &'p mut E,
292
80
    ) -> Result<TransactionTicket<'t, 'p>, FinanceError>
293
80
    where
294
80
        E: Connection<Database = sqlx::Postgres>,
295
80
    {
296
80
        let mut tr = conn.begin().await?;
297

            
298
80
        sqlx::query_file!(
299
            "sql/transaction_insert.sql",
300
            &self.id,
301
            &self.post_date,
302
            &self.enter_date
303
        )
304
80
        .execute(&mut *tr)
305
80
        .await?;
306

            
307
80
        Ok(TransactionTicket {
308
80
            sqltx: Some(tr),
309
80
            tx: self,
310
80
        })
311
80
    }
312
}
313

            
314
#[cfg(test)]
315
mod transaction_tests {
316
    use super::*;
317
    use crate::account::{Account, AccountBuilder};
318
    use crate::commodity::{Commodity, CommodityBuilder};
319
    use crate::split::SplitBuilder;
320
    #[cfg(feature = "testlog")]
321
    use env_logger;
322
    #[cfg(feature = "testlog")]
323
    use log;
324
    use sqlx::PgPool;
325
    use sqlx::types::chrono::Local;
326
    use tokio::sync::OnceCell;
327

            
328
    /// Context for keeping environment intact
329
    static CONTEXT: OnceCell<()> = OnceCell::const_new();
330
    static COMMODITY: OnceCell<Commodity> = OnceCell::const_new();
331
    static WALLET: OnceCell<Account> = OnceCell::const_new();
332
    static SHOP: OnceCell<Account> = OnceCell::const_new();
333

            
334
    static FOREIGN_COMMODITY: OnceCell<Commodity> = OnceCell::const_new();
335
    static ONLINE_SHOP: OnceCell<Account> = OnceCell::const_new();
336

            
337
    static FOREIGN_COMMODITY_2: OnceCell<Commodity> = OnceCell::const_new();
338
    static ONLINE_SHOP_2: OnceCell<Account> = OnceCell::const_new();
339

            
340
24
    async fn setup(pool: &PgPool) {
341
16
        let mut conn = pool.acquire().await.unwrap();
342

            
343
16
        CONTEXT
344
16
            .get_or_init(|| async {
345
                #[cfg(feature = "testlog")]
346
2
                let _ = env_logger::builder()
347
2
                    .is_test(true)
348
2
                    .filter_level(log::LevelFilter::Trace)
349
2
                    .try_init();
350
4
            })
351
16
            .await;
352

            
353
16
        COMMODITY
354
16
            .get_or_init(|| async {
355
2
                CommodityBuilder::new()
356
2
                    .fraction(1000)
357
2
                    .id(Uuid::new_v4())
358
2
                    .build()
359
2
                    .unwrap()
360
4
            })
361
16
            .await;
362
16
        COMMODITY.get().unwrap().commit(&mut *conn).await.unwrap();
363

            
364
16
        WALLET
365
16
            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
366
16
            .await;
367
16
        WALLET.get().unwrap().commit(&mut *conn).await.unwrap();
368

            
369
16
        SHOP.get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
370
16
            .await;
371
16
        SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
372

            
373
16
        FOREIGN_COMMODITY
374
16
            .get_or_init(|| async {
375
2
                CommodityBuilder::new()
376
2
                    .fraction(100)
377
2
                    .id(Uuid::new_v4())
378
2
                    .build()
379
2
                    .unwrap()
380
4
            })
381
16
            .await;
382
16
        FOREIGN_COMMODITY
383
16
            .get()
384
16
            .unwrap()
385
16
            .commit(&mut *conn)
386
16
            .await
387
16
            .unwrap();
388

            
389
16
        ONLINE_SHOP
390
16
            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
391
16
            .await;
392
16
        ONLINE_SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
393

            
394
16
        FOREIGN_COMMODITY_2
395
16
            .get_or_init(|| async {
396
2
                CommodityBuilder::new()
397
2
                    .fraction(100)
398
2
                    .id(Uuid::new_v4())
399
2
                    .build()
400
2
                    .unwrap()
401
4
            })
402
16
            .await;
403
16
        FOREIGN_COMMODITY_2
404
16
            .get()
405
16
            .unwrap()
406
16
            .commit(&mut *conn)
407
16
            .await
408
16
            .unwrap();
409

            
410
16
        ONLINE_SHOP_2
411
16
            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
412
16
            .await;
413
16
        ONLINE_SHOP_2
414
16
            .get()
415
16
            .unwrap()
416
16
            .commit(&mut *conn)
417
16
            .await
418
16
            .unwrap();
419
16
    }
420

            
421
    #[sqlx::test(migrations = "../migrations")]
422
    async fn test_transaction_store(pool: PgPool) {
423
        setup(&pool).await;
424
        let mut conn = pool.acquire().await.unwrap();
425

            
426
        let transaction = Transaction {
427
            id: Uuid::new_v4(),
428
            post_date: Local::now().into(),
429
            enter_date: Local::now().into(),
430
        };
431

            
432
        sqlx::query_file!(
433
            "sql/transaction_insert.sql",
434
            &transaction.id,
435
            &transaction.post_date,
436
            &transaction.enter_date
437
        )
438
        .execute(&mut *conn)
439
        .await
440
        .unwrap();
441

            
442
        let result = sqlx::query!("SELECT id, post_date FROM transactions")
443
            .fetch_one(&mut *conn)
444
            .await
445
            .unwrap();
446

            
447
        assert_eq!(transaction.id, result.id);
448
    }
449

            
450
    #[sqlx::test(migrations = "../migrations")]
451
    async fn test_transaction_builer(pool: PgPool) -> anyhow::Result<()> {
452
        setup(&pool).await;
453
        let build = Transaction::builder().id(Uuid::new_v4()).build();
454
        assert!(build.is_err());
455

            
456
        let build = Transaction::builder()
457
            .id(Uuid::new_v4())
458
            .post_date(Local::now().into())
459
            .enter_date(Local::now().into())
460
            .build();
461
        assert!(build.is_ok());
462

            
463
        Ok(())
464
    }
465

            
466
    #[sqlx::test(migrations = "../migrations")]
467
    async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
468
        setup(&pool).await;
469

            
470
        let tx = Transaction::builder()
471
            .id(Uuid::new_v4())
472
            .post_date(Local::now().into())
473
            .enter_date(Local::now().into())
474
            .build()?;
475

            
476
        let mut conn = pool.acquire().await?;
477
        let mut tr = tx.enter(&mut *conn).await?;
478
        tr.rollback().await?;
479
        let mut conn = pool.acquire().await?;
480
        assert!(
481
            sqlx::query!("SELECT id, post_date FROM transactions")
482
                .fetch_one(&mut *conn)
483
                .await
484
                .is_err()
485
        );
486

            
487
        let mut conn = pool.acquire().await?;
488
        let mut tr = tx.enter(&mut *conn).await?;
489
        assert!(tr.commit().await.is_err()); // Erroneous split
490

            
491
        Ok(())
492
    }
493

            
494
    #[sqlx::test(migrations = "../migrations")]
495
    async fn test_transaction_balance(pool: PgPool) -> anyhow::Result<()> {
496
        setup(&pool).await;
497

            
498
        let tx = Transaction::builder()
499
            .id(Uuid::new_v4())
500
            .post_date(Local::now().into())
501
            .enter_date(Local::now().into())
502
            .build()?;
503

            
504
        let mut conn = pool.acquire().await?;
505
        let mut tr = tx.enter(&mut *conn).await?;
506
        let split_spend = SplitBuilder::new()
507
            .account_id(WALLET.get().unwrap().id)
508
            .commodity_id(COMMODITY.get().unwrap().id)
509
            .id(Uuid::new_v4())
510
            .value_num(-100)
511
            .value_denom(1)
512
            .tx_id(tx.id)
513
            .build()?;
514

            
515
        let split_purchase = SplitBuilder::new()
516
            .account_id(SHOP.get().unwrap().id)
517
            .commodity_id(COMMODITY.get().unwrap().id)
518
            .id(Uuid::new_v4())
519
            .value_num(100)
520
            .value_denom(1)
521
            .tx_id(tx.id)
522
            .build()?;
523

            
524
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
525
        assert!(tr.commit().await.is_ok());
526

            
527
        let tx = Transaction::builder()
528
            .id(Uuid::new_v4())
529
            .post_date(Local::now().into())
530
            .enter_date(Local::now().into())
531
            .build()?;
532

            
533
        let mut conn = pool.acquire().await?;
534
        let mut tr = tx.enter(&mut *conn).await?;
535
        let split_spend = SplitBuilder::new()
536
            .account_id(WALLET.get().unwrap().id)
537
            .commodity_id(COMMODITY.get().unwrap().id)
538
            .id(Uuid::new_v4())
539
            .value_num(-100)
540
            .value_denom(1)
541
            .tx_id(tx.id)
542
            .build()?;
543

            
544
        let split_purchase = SplitBuilder::new()
545
            .account_id(SHOP.get().unwrap().id)
546
            .commodity_id(COMMODITY.get().unwrap().id)
547
            .id(Uuid::new_v4())
548
            .value_num(99)
549
            .value_denom(1)
550
            .tx_id(tx.id)
551
            .build()?;
552

            
553
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
554
        assert!(tr.commit().await.is_err());
555

            
556
        Ok(())
557
    }
558

            
559
    #[sqlx::test(migrations = "../migrations")]
560
    async fn test_transaction_multicurrency(pool: PgPool) -> anyhow::Result<()> {
561
        setup(&pool).await;
562

            
563
        let tx = Transaction::builder()
564
            .id(Uuid::new_v4())
565
            .post_date(Local::now().into())
566
            .enter_date(Local::now().into())
567
            .build()?;
568

            
569
        let mut conn = pool.acquire().await?;
570
        let mut tr = tx.enter(&mut *conn).await?;
571
        let split_spend = SplitBuilder::new()
572
            .account_id(WALLET.get().unwrap().id)
573
            .commodity_id(COMMODITY.get().unwrap().id)
574
            .id(Uuid::new_v4())
575
            .value_num(-1000)
576
            .value_denom(1)
577
            .tx_id(tx.id)
578
            .build()?;
579

            
580
        let split_purchase = SplitBuilder::new()
581
            .account_id(ONLINE_SHOP.get().unwrap().id)
582
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
583
            .id(Uuid::new_v4())
584
            .value_num(7)
585
            .value_denom(1)
586
            .tx_id(tx.id)
587
            .build()?;
588

            
589
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
590

            
591
        let conversion = Price {
592
            id: Uuid::new_v4(),
593
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
594
            currency_id: COMMODITY.get().unwrap().id,
595
            commodity_split: Some(split_purchase.id),
596
            currency_split: Some(split_spend.id),
597
            date: Local::now().into(),
598
            value_num: 1000,
599
            value_denom: 7,
600
        };
601

            
602
        tr.add_conversions(&[&conversion]).await?;
603

            
604
        assert!(tr.commit().await.is_ok());
605

            
606
        Ok(())
607
    }
608

            
609
    #[sqlx::test(migrations = "../migrations")]
610
    async fn test_transaction_multicurrency_fail(pool: PgPool) -> anyhow::Result<()> {
611
        setup(&pool).await;
612

            
613
        let tx = Transaction::builder()
614
            .id(Uuid::new_v4())
615
            .post_date(Local::now().into())
616
            .enter_date(Local::now().into())
617
            .build()?;
618

            
619
        let mut conn = pool.acquire().await?;
620
        let mut tr = tx.enter(&mut *conn).await?;
621

            
622
        let split_spend = SplitBuilder::new()
623
            .account_id(WALLET.get().unwrap().id)
624
            .commodity_id(COMMODITY.get().unwrap().id)
625
            .id(Uuid::new_v4())
626
            .value_num(-1000)
627
            .value_denom(1)
628
            .tx_id(tx.id)
629
            .build()?;
630

            
631
        let split_purchase = SplitBuilder::new()
632
            .account_id(ONLINE_SHOP.get().unwrap().id)
633
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
634
            .id(Uuid::new_v4())
635
            .value_num(7)
636
            .value_denom(1)
637
            .tx_id(tx.id)
638
            .build()?;
639

            
640
        let split_spend_2 = SplitBuilder::new()
641
            .account_id(WALLET.get().unwrap().id)
642
            .commodity_id(COMMODITY.get().unwrap().id)
643
            .id(Uuid::new_v4())
644
            .value_num(-1000)
645
            .value_denom(1)
646
            .tx_id(tx.id)
647
            .build()?;
648

            
649
        let split_purchase_2 = SplitBuilder::new()
650
            .account_id(ONLINE_SHOP_2.get().unwrap().id)
651
            .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
652
            .id(Uuid::new_v4())
653
            .value_num(10)
654
            .value_denom(1)
655
            .tx_id(tx.id)
656
            .build()?;
657

            
658
        tr.add_splits(&[
659
            &split_spend,
660
            &split_purchase,
661
            &split_spend_2,
662
            &split_purchase_2,
663
        ])
664
        .await?;
665

            
666
        let conversion = Price {
667
            id: Uuid::new_v4(),
668
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
669
            currency_id: COMMODITY.get().unwrap().id,
670
            commodity_split: Some(split_purchase.id),
671
            currency_split: Some(split_spend.id),
672
            date: Local::now().into(),
673
            value_num: 1000,
674
            value_denom: 7,
675
        };
676

            
677
        tr.add_conversions(&[&conversion]).await?;
678

            
679
        assert!(tr.commit().await.is_err()); // No second conversion
680

            
681
        Ok(())
682
    }
683

            
684
    #[sqlx::test(migrations = "../migrations")]
685
    async fn test_transaction_multicurrency_2(pool: PgPool) -> anyhow::Result<()> {
686
        setup(&pool).await;
687

            
688
        let tx = Transaction::builder()
689
            .id(Uuid::new_v4())
690
            .post_date(Local::now().into())
691
            .enter_date(Local::now().into())
692
            .build()?;
693

            
694
        let mut conn = pool.acquire().await?;
695
        let mut tr = tx.enter(&mut *conn).await?;
696

            
697
        let split_spend = SplitBuilder::new()
698
            .account_id(WALLET.get().unwrap().id)
699
            .commodity_id(COMMODITY.get().unwrap().id)
700
            .id(Uuid::new_v4())
701
            .value_num(-1000)
702
            .value_denom(1)
703
            .tx_id(tx.id)
704
            .build()?;
705

            
706
        let split_purchase = SplitBuilder::new()
707
            .account_id(ONLINE_SHOP.get().unwrap().id)
708
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
709
            .id(Uuid::new_v4())
710
            .value_num(7)
711
            .value_denom(1)
712
            .tx_id(tx.id)
713
            .build()?;
714

            
715
        let split_spend_2 = SplitBuilder::new()
716
            .account_id(WALLET.get().unwrap().id)
717
            .commodity_id(COMMODITY.get().unwrap().id)
718
            .id(Uuid::new_v4())
719
            .value_num(-1000)
720
            .value_denom(1)
721
            .tx_id(tx.id)
722
            .build()?;
723

            
724
        let split_purchase_2 = SplitBuilder::new()
725
            .account_id(ONLINE_SHOP_2.get().unwrap().id)
726
            .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
727
            .id(Uuid::new_v4())
728
            .value_num(10)
729
            .value_denom(1)
730
            .tx_id(tx.id)
731
            .build()?;
732

            
733
        tr.add_splits(&[
734
            &split_spend,
735
            &split_purchase,
736
            &split_spend_2,
737
            &split_purchase_2,
738
        ])
739
        .await?;
740

            
741
        let conversion = Price {
742
            id: Uuid::new_v4(),
743
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
744
            currency_id: COMMODITY.get().unwrap().id,
745
            commodity_split: Some(split_purchase.id),
746
            currency_split: Some(split_spend.id),
747
            date: Local::now().into(),
748
            value_num: 1000,
749
            value_denom: 7,
750
        };
751

            
752
        let conversion_2 = Price {
753
            id: Uuid::new_v4(),
754
            commodity_id: FOREIGN_COMMODITY_2.get().unwrap().id,
755
            currency_id: COMMODITY.get().unwrap().id,
756
            commodity_split: Some(split_purchase_2.id),
757
            currency_split: Some(split_spend_2.id),
758
            date: Local::now().into(),
759
            value_num: 1000,
760
            value_denom: 10,
761
        };
762

            
763
        tr.add_conversions(&[&conversion, &conversion_2]).await?;
764

            
765
        assert!(tr.commit().await.is_ok());
766

            
767
        Ok(())
768
    }
769

            
770
    #[sqlx::test(migrations = "../migrations")]
771
    async fn test_transaction_unbalanced(pool: PgPool) -> anyhow::Result<()> {
772
        setup(&pool).await;
773

            
774
        let tx = Transaction::builder()
775
            .id(Uuid::new_v4())
776
            .post_date(Local::now().into())
777
            .enter_date(Local::now().into())
778
            .build()?;
779

            
780
        let mut conn = pool.acquire().await?;
781
        let mut tr = tx.enter(&mut *conn).await?;
782
        let split_spend = SplitBuilder::new()
783
            .account_id(WALLET.get().unwrap().id)
784
            .commodity_id(COMMODITY.get().unwrap().id)
785
            .id(Uuid::new_v4())
786
            .value_num(-100)
787
            .value_denom(1)
788
            .tx_id(tx.id)
789
            .build()?;
790

            
791
        let split_purchase = SplitBuilder::new()
792
            .account_id(SHOP.get().unwrap().id)
793
            .commodity_id(COMMODITY.get().unwrap().id)
794
            .id(Uuid::new_v4())
795
            .value_num(90)
796
            .value_denom(1)
797
            .tx_id(tx.id)
798
            .build()?;
799

            
800
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
801
        assert!(tr.commit().await.is_err());
802

            
803
        Ok(())
804
    }
805
}