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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
272
20
    pub async fn add_split_tags(
273
20
        &mut self,
274
20
        split_tags: &[(Uuid, Tag)],
275
20
    ) -> Result<&'t Transaction, FinanceError> {
276
1
        if let Some(sqltx) = &mut self.sqltx {
277
2
            for (split_id, tag) in split_tags {
278
2
                tag.commit(&mut **sqltx).await?;
279
2
                query_file!("sql/split_tag_set.sql", split_id, tag.id)
280
2
                    .execute(&mut **sqltx)
281
2
                    .await?;
282
            }
283
1
            Ok(self.tx)
284
        } else {
285
            Err(FinanceError::Internal(
286
                t!("Adding split tags failed").to_string(),
287
            ))
288
        }
289
1
    }
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

            
310
impl<'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
148
    pub async fn enter<'p, E>(
327
148
        &'t self,
328
148
        conn: &'p mut E,
329
148
    ) -> Result<TransactionTicket<'t, 'p>, FinanceError>
330
148
    where
331
148
        E: Connection<Database = sqlx::Postgres>,
332
148
    {
333
148
        let mut tr = conn.begin().await?;
334

            
335
148
        sqlx::query_file!(
336
            "sql/transaction_insert.sql",
337
            &self.id,
338
            &self.post_date,
339
            &self.enter_date
340
        )
341
148
        .execute(&mut *tr)
342
148
        .await?;
343

            
344
148
        Ok(TransactionTicket {
345
148
            sqltx: Some(tr),
346
148
            tx: self,
347
148
        })
348
148
    }
349
}
350

            
351
#[cfg(test)]
352
mod 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
8
    async fn setup(pool: &PgPool) {
378
8
        let mut conn = pool.acquire().await.unwrap();
379

            
380
8
        CONTEXT
381
8
            .get_or_init(|| async {
382
                #[cfg(feature = "testlog")]
383
1
                let _ = env_logger::builder()
384
1
                    .is_test(true)
385
1
                    .filter_level(log::LevelFilter::Trace)
386
1
                    .try_init();
387
2
            })
388
8
            .await;
389

            
390
8
        COMMODITY
391
8
            .get_or_init(|| async { CommodityBuilder::new().id(Uuid::new_v4()).build().unwrap() })
392
8
            .await;
393
8
        COMMODITY.get().unwrap().commit(&mut *conn).await.unwrap();
394

            
395
8
        WALLET
396
8
            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
397
8
            .await;
398
8
        WALLET.get().unwrap().commit(&mut *conn).await.unwrap();
399

            
400
8
        SHOP.get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
401
8
            .await;
402
8
        SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
403

            
404
8
        FOREIGN_COMMODITY
405
8
            .get_or_init(|| async { CommodityBuilder::new().id(Uuid::new_v4()).build().unwrap() })
406
8
            .await;
407
8
        FOREIGN_COMMODITY
408
8
            .get()
409
8
            .unwrap()
410
8
            .commit(&mut *conn)
411
8
            .await
412
8
            .unwrap();
413

            
414
8
        ONLINE_SHOP
415
8
            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
416
8
            .await;
417
8
        ONLINE_SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
418

            
419
8
        FOREIGN_COMMODITY_2
420
8
            .get_or_init(|| async { CommodityBuilder::new().id(Uuid::new_v4()).build().unwrap() })
421
8
            .await;
422
8
        FOREIGN_COMMODITY_2
423
8
            .get()
424
8
            .unwrap()
425
8
            .commit(&mut *conn)
426
8
            .await
427
8
            .unwrap();
428

            
429
8
        ONLINE_SHOP_2
430
8
            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
431
8
            .await;
432
8
        ONLINE_SHOP_2
433
8
            .get()
434
8
            .unwrap()
435
8
            .commit(&mut *conn)
436
8
            .await
437
8
            .unwrap();
438
8
    }
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
    #[sqlx::test(migrations = "../migrations")]
514
    async fn test_transaction_balance(pool: PgPool) -> anyhow::Result<()> {
515
        setup(&pool).await;
516

            
517
        let tx = Transaction::builder()
518
            .id(Uuid::new_v4())
519
            .post_date(Local::now().into())
520
            .enter_date(Local::now().into())
521
            .build()?;
522

            
523
        let mut conn = pool.acquire().await?;
524
        let mut tr = tx.enter(&mut *conn).await?;
525
        let split_spend = SplitBuilder::new()
526
            .account_id(WALLET.get().unwrap().id)
527
            .commodity_id(COMMODITY.get().unwrap().id)
528
            .id(Uuid::new_v4())
529
            .value_num(-100)
530
            .value_denom(1)
531
            .tx_id(tx.id)
532
            .build()?;
533

            
534
        let split_purchase = SplitBuilder::new()
535
            .account_id(SHOP.get().unwrap().id)
536
            .commodity_id(COMMODITY.get().unwrap().id)
537
            .id(Uuid::new_v4())
538
            .value_num(100)
539
            .value_denom(1)
540
            .tx_id(tx.id)
541
            .build()?;
542

            
543
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
544
        assert!(tr.commit().await.is_ok());
545

            
546
        let tx = Transaction::builder()
547
            .id(Uuid::new_v4())
548
            .post_date(Local::now().into())
549
            .enter_date(Local::now().into())
550
            .build()?;
551

            
552
        let mut conn = pool.acquire().await?;
553
        let mut tr = tx.enter(&mut *conn).await?;
554
        let split_spend = SplitBuilder::new()
555
            .account_id(WALLET.get().unwrap().id)
556
            .commodity_id(COMMODITY.get().unwrap().id)
557
            .id(Uuid::new_v4())
558
            .value_num(-100)
559
            .value_denom(1)
560
            .tx_id(tx.id)
561
            .build()?;
562

            
563
        let split_purchase = SplitBuilder::new()
564
            .account_id(SHOP.get().unwrap().id)
565
            .commodity_id(COMMODITY.get().unwrap().id)
566
            .id(Uuid::new_v4())
567
            .value_num(99)
568
            .value_denom(1)
569
            .tx_id(tx.id)
570
            .build()?;
571

            
572
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
573
        assert!(tr.commit().await.is_err());
574

            
575
        Ok(())
576
    }
577

            
578
    #[sqlx::test(migrations = "../migrations")]
579
    async fn test_transaction_multicurrency(pool: PgPool) -> anyhow::Result<()> {
580
        setup(&pool).await;
581

            
582
        let tx = Transaction::builder()
583
            .id(Uuid::new_v4())
584
            .post_date(Local::now().into())
585
            .enter_date(Local::now().into())
586
            .build()?;
587

            
588
        let mut conn = pool.acquire().await?;
589
        let mut tr = tx.enter(&mut *conn).await?;
590
        let split_spend = SplitBuilder::new()
591
            .account_id(WALLET.get().unwrap().id)
592
            .commodity_id(COMMODITY.get().unwrap().id)
593
            .id(Uuid::new_v4())
594
            .value_num(-1000)
595
            .value_denom(1)
596
            .tx_id(tx.id)
597
            .build()?;
598

            
599
        let split_purchase = SplitBuilder::new()
600
            .account_id(ONLINE_SHOP.get().unwrap().id)
601
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
602
            .id(Uuid::new_v4())
603
            .value_num(7)
604
            .value_denom(1)
605
            .tx_id(tx.id)
606
            .build()?;
607

            
608
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
609

            
610
        let conversion = Price {
611
            id: Uuid::new_v4(),
612
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
613
            currency_id: COMMODITY.get().unwrap().id,
614
            commodity_split: Some(split_purchase.id),
615
            currency_split: Some(split_spend.id),
616
            date: Local::now().into(),
617
            value_num: 1000,
618
            value_denom: 7,
619
        };
620

            
621
        tr.add_conversions(&[&conversion]).await?;
622

            
623
        assert!(tr.commit().await.is_ok());
624

            
625
        Ok(())
626
    }
627

            
628
    #[sqlx::test(migrations = "../migrations")]
629
    async fn test_transaction_multicurrency_fail(pool: PgPool) -> anyhow::Result<()> {
630
        setup(&pool).await;
631

            
632
        let tx = Transaction::builder()
633
            .id(Uuid::new_v4())
634
            .post_date(Local::now().into())
635
            .enter_date(Local::now().into())
636
            .build()?;
637

            
638
        let mut conn = pool.acquire().await?;
639
        let mut tr = tx.enter(&mut *conn).await?;
640

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

            
650
        let split_purchase = SplitBuilder::new()
651
            .account_id(ONLINE_SHOP.get().unwrap().id)
652
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
653
            .id(Uuid::new_v4())
654
            .value_num(7)
655
            .value_denom(1)
656
            .tx_id(tx.id)
657
            .build()?;
658

            
659
        let split_spend_2 = SplitBuilder::new()
660
            .account_id(WALLET.get().unwrap().id)
661
            .commodity_id(COMMODITY.get().unwrap().id)
662
            .id(Uuid::new_v4())
663
            .value_num(-1000)
664
            .value_denom(1)
665
            .tx_id(tx.id)
666
            .build()?;
667

            
668
        let split_purchase_2 = SplitBuilder::new()
669
            .account_id(ONLINE_SHOP_2.get().unwrap().id)
670
            .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
671
            .id(Uuid::new_v4())
672
            .value_num(10)
673
            .value_denom(1)
674
            .tx_id(tx.id)
675
            .build()?;
676

            
677
        tr.add_splits(&[
678
            &split_spend,
679
            &split_purchase,
680
            &split_spend_2,
681
            &split_purchase_2,
682
        ])
683
        .await?;
684

            
685
        let conversion = Price {
686
            id: Uuid::new_v4(),
687
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
688
            currency_id: COMMODITY.get().unwrap().id,
689
            commodity_split: Some(split_purchase.id),
690
            currency_split: Some(split_spend.id),
691
            date: Local::now().into(),
692
            value_num: 1000,
693
            value_denom: 7,
694
        };
695

            
696
        tr.add_conversions(&[&conversion]).await?;
697

            
698
        assert!(tr.commit().await.is_err()); // No second conversion
699

            
700
        Ok(())
701
    }
702

            
703
    #[sqlx::test(migrations = "../migrations")]
704
    async fn test_transaction_multicurrency_2(pool: PgPool) -> anyhow::Result<()> {
705
        setup(&pool).await;
706

            
707
        let tx = Transaction::builder()
708
            .id(Uuid::new_v4())
709
            .post_date(Local::now().into())
710
            .enter_date(Local::now().into())
711
            .build()?;
712

            
713
        let mut conn = pool.acquire().await?;
714
        let mut tr = tx.enter(&mut *conn).await?;
715

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

            
725
        let split_purchase = SplitBuilder::new()
726
            .account_id(ONLINE_SHOP.get().unwrap().id)
727
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
728
            .id(Uuid::new_v4())
729
            .value_num(7)
730
            .value_denom(1)
731
            .tx_id(tx.id)
732
            .build()?;
733

            
734
        let split_spend_2 = SplitBuilder::new()
735
            .account_id(WALLET.get().unwrap().id)
736
            .commodity_id(COMMODITY.get().unwrap().id)
737
            .id(Uuid::new_v4())
738
            .value_num(-1000)
739
            .value_denom(1)
740
            .tx_id(tx.id)
741
            .build()?;
742

            
743
        let split_purchase_2 = SplitBuilder::new()
744
            .account_id(ONLINE_SHOP_2.get().unwrap().id)
745
            .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
746
            .id(Uuid::new_v4())
747
            .value_num(10)
748
            .value_denom(1)
749
            .tx_id(tx.id)
750
            .build()?;
751

            
752
        tr.add_splits(&[
753
            &split_spend,
754
            &split_purchase,
755
            &split_spend_2,
756
            &split_purchase_2,
757
        ])
758
        .await?;
759

            
760
        let conversion = Price {
761
            id: Uuid::new_v4(),
762
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
763
            currency_id: COMMODITY.get().unwrap().id,
764
            commodity_split: Some(split_purchase.id),
765
            currency_split: Some(split_spend.id),
766
            date: Local::now().into(),
767
            value_num: 1000,
768
            value_denom: 7,
769
        };
770

            
771
        let conversion_2 = Price {
772
            id: Uuid::new_v4(),
773
            commodity_id: FOREIGN_COMMODITY_2.get().unwrap().id,
774
            currency_id: COMMODITY.get().unwrap().id,
775
            commodity_split: Some(split_purchase_2.id),
776
            currency_split: Some(split_spend_2.id),
777
            date: Local::now().into(),
778
            value_num: 1000,
779
            value_denom: 10,
780
        };
781

            
782
        tr.add_conversions(&[&conversion, &conversion_2]).await?;
783

            
784
        assert!(tr.commit().await.is_ok());
785

            
786
        Ok(())
787
    }
788

            
789
    #[sqlx::test(migrations = "../migrations")]
790
    async fn test_transaction_unbalanced(pool: PgPool) -> anyhow::Result<()> {
791
        setup(&pool).await;
792

            
793
        let tx = Transaction::builder()
794
            .id(Uuid::new_v4())
795
            .post_date(Local::now().into())
796
            .enter_date(Local::now().into())
797
            .build()?;
798

            
799
        let mut conn = pool.acquire().await?;
800
        let mut tr = tx.enter(&mut *conn).await?;
801
        let split_spend = SplitBuilder::new()
802
            .account_id(WALLET.get().unwrap().id)
803
            .commodity_id(COMMODITY.get().unwrap().id)
804
            .id(Uuid::new_v4())
805
            .value_num(-100)
806
            .value_denom(1)
807
            .tx_id(tx.id)
808
            .build()?;
809

            
810
        let split_purchase = SplitBuilder::new()
811
            .account_id(SHOP.get().unwrap().id)
812
            .commodity_id(COMMODITY.get().unwrap().id)
813
            .id(Uuid::new_v4())
814
            .value_num(90)
815
            .value_denom(1)
816
            .tx_id(tx.id)
817
            .build()?;
818

            
819
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
820
        assert!(tr.commit().await.is_err());
821

            
822
        Ok(())
823
    }
824
}