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

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

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

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

            
78
118
                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
116
                }
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
6
                for (commodity_id, split_group) in &splits_by_commodity {
113
6
                    if *commodity_id == base_commodity {
114
                        // Already handled
115
2
                        continue;
116
4
                    }
117

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

            
124
4
                    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
3
                    }
131

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

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

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

            
173
3
                        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
118
            sqltx.commit().await?;
185
118
            Ok(self.tx)
186
        } else {
187
            Err(FinanceError::Internal(
188
                t!("Attempt to commit the empty ticket").to_string(),
189
            ))
190
        }
191
122
    }
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
1041
    pub async fn add_splits(&mut self, splits: &[&Split]) -> Result<&'t Transaction, FinanceError> {
212
121
        if let Some(sqltx) = &mut self.sqltx {
213
246
            for s in splits {
214
246
                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
246
                }
219
246
                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
246
                .execute(&mut **sqltx)
232
246
                .await?;
233
            }
234
121
            Ok(self.tx)
235
        } else {
236
            Err(FinanceError::Internal(
237
                t!("Adding splits failed").to_string(),
238
            ))
239
        }
240
121
    }
241

            
242
12
    pub async fn add_conversions(
243
12
        &mut self,
244
12
        prices: &[&Price],
245
12
    ) -> 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
126
    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
9
    pub async fn add_split_tags(
273
9
        &mut self,
274
9
        split_tags: &[(Uuid, Tag)],
275
9
    ) -> 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
123
    pub async fn enter<'p, E>(
327
123
        &'t self,
328
123
        conn: &'p mut E,
329
123
    ) -> Result<TransactionTicket<'t, 'p>, FinanceError>
330
123
    where
331
123
        E: Connection<Database = sqlx::Postgres>,
332
123
    {
333
123
        let mut tr = conn.begin().await?;
334

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

            
344
123
        Ok(TransactionTicket {
345
123
            sqltx: Some(tr),
346
123
            tx: self,
347
123
        })
348
123
    }
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 {
392
1
                CommodityBuilder::new()
393
1
                    .fraction(1000)
394
1
                    .id(Uuid::new_v4())
395
1
                    .build()
396
1
                    .unwrap()
397
2
            })
398
8
            .await;
399
8
        COMMODITY.get().unwrap().commit(&mut *conn).await.unwrap();
400

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

            
406
8
        SHOP.get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
407
8
            .await;
408
8
        SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
409

            
410
8
        FOREIGN_COMMODITY
411
8
            .get_or_init(|| async {
412
1
                CommodityBuilder::new()
413
1
                    .fraction(100)
414
1
                    .id(Uuid::new_v4())
415
1
                    .build()
416
1
                    .unwrap()
417
2
            })
418
8
            .await;
419
8
        FOREIGN_COMMODITY
420
8
            .get()
421
8
            .unwrap()
422
8
            .commit(&mut *conn)
423
8
            .await
424
8
            .unwrap();
425

            
426
8
        ONLINE_SHOP
427
8
            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
428
8
            .await;
429
8
        ONLINE_SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
430

            
431
8
        FOREIGN_COMMODITY_2
432
8
            .get_or_init(|| async {
433
1
                CommodityBuilder::new()
434
1
                    .fraction(100)
435
1
                    .id(Uuid::new_v4())
436
1
                    .build()
437
1
                    .unwrap()
438
2
            })
439
8
            .await;
440
8
        FOREIGN_COMMODITY_2
441
8
            .get()
442
8
            .unwrap()
443
8
            .commit(&mut *conn)
444
8
            .await
445
8
            .unwrap();
446

            
447
8
        ONLINE_SHOP_2
448
8
            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
449
8
            .await;
450
8
        ONLINE_SHOP_2
451
8
            .get()
452
8
            .unwrap()
453
8
            .commit(&mut *conn)
454
8
            .await
455
8
            .unwrap();
456
8
    }
457

            
458
    #[sqlx::test(migrations = "../migrations")]
459
    async fn test_transaction_store(pool: PgPool) {
460
        setup(&pool).await;
461
        let mut conn = pool.acquire().await.unwrap();
462

            
463
        let transaction = Transaction {
464
            id: Uuid::new_v4(),
465
            post_date: Local::now().into(),
466
            enter_date: Local::now().into(),
467
        };
468

            
469
        sqlx::query_file!(
470
            "sql/transaction_insert.sql",
471
            &transaction.id,
472
            &transaction.post_date,
473
            &transaction.enter_date
474
        )
475
        .execute(&mut *conn)
476
        .await
477
        .unwrap();
478

            
479
        let result = sqlx::query!("SELECT id, post_date FROM transactions")
480
            .fetch_one(&mut *conn)
481
            .await
482
            .unwrap();
483

            
484
        assert_eq!(transaction.id, result.id);
485
    }
486

            
487
    #[sqlx::test(migrations = "../migrations")]
488
    async fn test_transaction_builer(pool: PgPool) -> anyhow::Result<()> {
489
        setup(&pool).await;
490
        let build = Transaction::builder().id(Uuid::new_v4()).build();
491
        assert!(build.is_err());
492

            
493
        let build = Transaction::builder()
494
            .id(Uuid::new_v4())
495
            .post_date(Local::now().into())
496
            .enter_date(Local::now().into())
497
            .build();
498
        assert!(build.is_ok());
499

            
500
        Ok(())
501
    }
502

            
503
    #[sqlx::test(migrations = "../migrations")]
504
    async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
505
        setup(&pool).await;
506

            
507
        let tx = Transaction::builder()
508
            .id(Uuid::new_v4())
509
            .post_date(Local::now().into())
510
            .enter_date(Local::now().into())
511
            .build()?;
512

            
513
        let mut conn = pool.acquire().await?;
514
        let mut tr = tx.enter(&mut *conn).await?;
515
        tr.rollback().await?;
516
        let mut conn = pool.acquire().await?;
517
        assert!(
518
            sqlx::query!("SELECT id, post_date FROM transactions")
519
                .fetch_one(&mut *conn)
520
                .await
521
                .is_err()
522
        );
523

            
524
        let mut conn = pool.acquire().await?;
525
        let mut tr = tx.enter(&mut *conn).await?;
526
        assert!(tr.commit().await.is_err()); // Erroneous split
527

            
528
        Ok(())
529
    }
530

            
531
    #[sqlx::test(migrations = "../migrations")]
532
    async fn test_transaction_balance(pool: PgPool) -> anyhow::Result<()> {
533
        setup(&pool).await;
534

            
535
        let tx = Transaction::builder()
536
            .id(Uuid::new_v4())
537
            .post_date(Local::now().into())
538
            .enter_date(Local::now().into())
539
            .build()?;
540

            
541
        let mut conn = pool.acquire().await?;
542
        let mut tr = tx.enter(&mut *conn).await?;
543
        let split_spend = SplitBuilder::new()
544
            .account_id(WALLET.get().unwrap().id)
545
            .commodity_id(COMMODITY.get().unwrap().id)
546
            .id(Uuid::new_v4())
547
            .value_num(-100)
548
            .value_denom(1)
549
            .tx_id(tx.id)
550
            .build()?;
551

            
552
        let split_purchase = SplitBuilder::new()
553
            .account_id(SHOP.get().unwrap().id)
554
            .commodity_id(COMMODITY.get().unwrap().id)
555
            .id(Uuid::new_v4())
556
            .value_num(100)
557
            .value_denom(1)
558
            .tx_id(tx.id)
559
            .build()?;
560

            
561
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
562
        assert!(tr.commit().await.is_ok());
563

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

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

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

            
590
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
591
        assert!(tr.commit().await.is_err());
592

            
593
        Ok(())
594
    }
595

            
596
    #[sqlx::test(migrations = "../migrations")]
597
    async fn test_transaction_multicurrency(pool: PgPool) -> anyhow::Result<()> {
598
        setup(&pool).await;
599

            
600
        let tx = Transaction::builder()
601
            .id(Uuid::new_v4())
602
            .post_date(Local::now().into())
603
            .enter_date(Local::now().into())
604
            .build()?;
605

            
606
        let mut conn = pool.acquire().await?;
607
        let mut tr = tx.enter(&mut *conn).await?;
608
        let split_spend = SplitBuilder::new()
609
            .account_id(WALLET.get().unwrap().id)
610
            .commodity_id(COMMODITY.get().unwrap().id)
611
            .id(Uuid::new_v4())
612
            .value_num(-1000)
613
            .value_denom(1)
614
            .tx_id(tx.id)
615
            .build()?;
616

            
617
        let split_purchase = SplitBuilder::new()
618
            .account_id(ONLINE_SHOP.get().unwrap().id)
619
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
620
            .id(Uuid::new_v4())
621
            .value_num(7)
622
            .value_denom(1)
623
            .tx_id(tx.id)
624
            .build()?;
625

            
626
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
627

            
628
        let conversion = Price {
629
            id: Uuid::new_v4(),
630
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
631
            currency_id: COMMODITY.get().unwrap().id,
632
            commodity_split: Some(split_purchase.id),
633
            currency_split: Some(split_spend.id),
634
            date: Local::now().into(),
635
            value_num: 1000,
636
            value_denom: 7,
637
        };
638

            
639
        tr.add_conversions(&[&conversion]).await?;
640

            
641
        assert!(tr.commit().await.is_ok());
642

            
643
        Ok(())
644
    }
645

            
646
    #[sqlx::test(migrations = "../migrations")]
647
    async fn test_transaction_multicurrency_fail(pool: PgPool) -> anyhow::Result<()> {
648
        setup(&pool).await;
649

            
650
        let tx = Transaction::builder()
651
            .id(Uuid::new_v4())
652
            .post_date(Local::now().into())
653
            .enter_date(Local::now().into())
654
            .build()?;
655

            
656
        let mut conn = pool.acquire().await?;
657
        let mut tr = tx.enter(&mut *conn).await?;
658

            
659
        let split_spend = 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 = SplitBuilder::new()
669
            .account_id(ONLINE_SHOP.get().unwrap().id)
670
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
671
            .id(Uuid::new_v4())
672
            .value_num(7)
673
            .value_denom(1)
674
            .tx_id(tx.id)
675
            .build()?;
676

            
677
        let split_spend_2 = SplitBuilder::new()
678
            .account_id(WALLET.get().unwrap().id)
679
            .commodity_id(COMMODITY.get().unwrap().id)
680
            .id(Uuid::new_v4())
681
            .value_num(-1000)
682
            .value_denom(1)
683
            .tx_id(tx.id)
684
            .build()?;
685

            
686
        let split_purchase_2 = SplitBuilder::new()
687
            .account_id(ONLINE_SHOP_2.get().unwrap().id)
688
            .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
689
            .id(Uuid::new_v4())
690
            .value_num(10)
691
            .value_denom(1)
692
            .tx_id(tx.id)
693
            .build()?;
694

            
695
        tr.add_splits(&[
696
            &split_spend,
697
            &split_purchase,
698
            &split_spend_2,
699
            &split_purchase_2,
700
        ])
701
        .await?;
702

            
703
        let conversion = Price {
704
            id: Uuid::new_v4(),
705
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
706
            currency_id: COMMODITY.get().unwrap().id,
707
            commodity_split: Some(split_purchase.id),
708
            currency_split: Some(split_spend.id),
709
            date: Local::now().into(),
710
            value_num: 1000,
711
            value_denom: 7,
712
        };
713

            
714
        tr.add_conversions(&[&conversion]).await?;
715

            
716
        assert!(tr.commit().await.is_err()); // No second conversion
717

            
718
        Ok(())
719
    }
720

            
721
    #[sqlx::test(migrations = "../migrations")]
722
    async fn test_transaction_multicurrency_2(pool: PgPool) -> anyhow::Result<()> {
723
        setup(&pool).await;
724

            
725
        let tx = Transaction::builder()
726
            .id(Uuid::new_v4())
727
            .post_date(Local::now().into())
728
            .enter_date(Local::now().into())
729
            .build()?;
730

            
731
        let mut conn = pool.acquire().await?;
732
        let mut tr = tx.enter(&mut *conn).await?;
733

            
734
        let split_spend = 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 = SplitBuilder::new()
744
            .account_id(ONLINE_SHOP.get().unwrap().id)
745
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
746
            .id(Uuid::new_v4())
747
            .value_num(7)
748
            .value_denom(1)
749
            .tx_id(tx.id)
750
            .build()?;
751

            
752
        let split_spend_2 = SplitBuilder::new()
753
            .account_id(WALLET.get().unwrap().id)
754
            .commodity_id(COMMODITY.get().unwrap().id)
755
            .id(Uuid::new_v4())
756
            .value_num(-1000)
757
            .value_denom(1)
758
            .tx_id(tx.id)
759
            .build()?;
760

            
761
        let split_purchase_2 = SplitBuilder::new()
762
            .account_id(ONLINE_SHOP_2.get().unwrap().id)
763
            .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
764
            .id(Uuid::new_v4())
765
            .value_num(10)
766
            .value_denom(1)
767
            .tx_id(tx.id)
768
            .build()?;
769

            
770
        tr.add_splits(&[
771
            &split_spend,
772
            &split_purchase,
773
            &split_spend_2,
774
            &split_purchase_2,
775
        ])
776
        .await?;
777

            
778
        let conversion = Price {
779
            id: Uuid::new_v4(),
780
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
781
            currency_id: COMMODITY.get().unwrap().id,
782
            commodity_split: Some(split_purchase.id),
783
            currency_split: Some(split_spend.id),
784
            date: Local::now().into(),
785
            value_num: 1000,
786
            value_denom: 7,
787
        };
788

            
789
        let conversion_2 = Price {
790
            id: Uuid::new_v4(),
791
            commodity_id: FOREIGN_COMMODITY_2.get().unwrap().id,
792
            currency_id: COMMODITY.get().unwrap().id,
793
            commodity_split: Some(split_purchase_2.id),
794
            currency_split: Some(split_spend_2.id),
795
            date: Local::now().into(),
796
            value_num: 1000,
797
            value_denom: 10,
798
        };
799

            
800
        tr.add_conversions(&[&conversion, &conversion_2]).await?;
801

            
802
        assert!(tr.commit().await.is_ok());
803

            
804
        Ok(())
805
    }
806

            
807
    #[sqlx::test(migrations = "../migrations")]
808
    async fn test_transaction_unbalanced(pool: PgPool) -> anyhow::Result<()> {
809
        setup(&pool).await;
810

            
811
        let tx = Transaction::builder()
812
            .id(Uuid::new_v4())
813
            .post_date(Local::now().into())
814
            .enter_date(Local::now().into())
815
            .build()?;
816

            
817
        let mut conn = pool.acquire().await?;
818
        let mut tr = tx.enter(&mut *conn).await?;
819
        let split_spend = SplitBuilder::new()
820
            .account_id(WALLET.get().unwrap().id)
821
            .commodity_id(COMMODITY.get().unwrap().id)
822
            .id(Uuid::new_v4())
823
            .value_num(-100)
824
            .value_denom(1)
825
            .tx_id(tx.id)
826
            .build()?;
827

            
828
        let split_purchase = SplitBuilder::new()
829
            .account_id(SHOP.get().unwrap().id)
830
            .commodity_id(COMMODITY.get().unwrap().id)
831
            .id(Uuid::new_v4())
832
            .value_num(90)
833
            .value_denom(1)
834
            .tx_id(tx.id)
835
            .build()?;
836

            
837
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
838
        assert!(tr.commit().await.is_err());
839

            
840
        Ok(())
841
    }
842
}