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

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

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

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

            
78
168
                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
166
                }
83
            } else {
84
                // Multi-currency transaction. Just pick the first commodity as
85
                // the base for computations.
86
4
                let base_commodity = distinct_commodities[0];
87

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

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

            
99
                // Sum the "base"
100
4
                if let Some(base_splits) = splits_by_commodity.get(&base_commodity) {
101
6
                    for s in base_splits {
102
6
                        transaction_sum += Rational64::new(s.value_num, s.value_denom);
103
6
                    }
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
8
                for (commodity_id, split_group) in &splits_by_commodity {
113
8
                    if *commodity_id == base_commodity {
114
                        // Already handled
115
3
                        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
3
                if transaction_sum != 0.into() {
178
                    return Err(FinanceError::Transaction(TransactionError::Build(
179
                        t!("Unbalanced Transaction after conversion: sum != 0").to_string(),
180
                    )));
181
3
                }
182
            }
183

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

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

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

            
272
60
    pub async fn add_split_tags(
273
60
        &mut self,
274
60
        split_tags: &[(Uuid, Tag)],
275
60
    ) -> Result<&'t Transaction, FinanceError> {
276
4
        if let Some(sqltx) = &mut self.sqltx {
277
6
            for (split_id, tag) in split_tags {
278
6
                let tag_id = tag.commit(&mut **sqltx).await?;
279
6
                query_file!("sql/split_tag_set.sql", split_id, tag_id)
280
6
                    .execute(&mut **sqltx)
281
6
                    .await?;
282
            }
283
4
            Ok(self.tx)
284
        } else {
285
            Err(FinanceError::Internal(
286
                t!("Adding split tags failed").to_string(),
287
            ))
288
        }
289
4
    }
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
174
    pub async fn enter<'p, E>(
327
174
        &'t self,
328
174
        conn: &'p mut E,
329
174
    ) -> Result<TransactionTicket<'t, 'p>, FinanceError>
330
174
    where
331
174
        E: Connection<Database = sqlx::Postgres>,
332
174
    {
333
174
        let mut tr = conn.begin().await?;
334

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

            
344
174
        Ok(TransactionTicket {
345
174
            sqltx: Some(tr),
346
174
            tx: self,
347
174
        })
348
174
    }
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
10
    async fn setup(pool: &PgPool) {
378
10
        let mut conn = pool.acquire().await.unwrap();
379

            
380
10
        CONTEXT
381
10
            .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
10
            .await;
389

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

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

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

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

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

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

            
429
10
        ONLINE_SHOP_2
430
10
            .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
431
10
            .await;
432
10
        ONLINE_SHOP_2
433
10
            .get()
434
10
            .unwrap()
435
10
            .commit(&mut *conn)
436
10
            .await
437
10
            .unwrap();
438
10
    }
439

            
440
    #[sqlx::test(migrations = "../migrations")]
441
    async fn test_transaction_store(pool: PgPool) {
442
        setup(&pool).await;
443
        let mut conn = pool.acquire().await.unwrap();
444

            
445
        let transaction = Transaction {
446
            id: Uuid::new_v4(),
447
            post_date: Local::now().into(),
448
            enter_date: Local::now().into(),
449
        };
450

            
451
        sqlx::query_file!(
452
            "sql/transaction_insert.sql",
453
            &transaction.id,
454
            &transaction.post_date,
455
            &transaction.enter_date
456
        )
457
        .execute(&mut *conn)
458
        .await
459
        .unwrap();
460

            
461
        let result = sqlx::query!("SELECT id, post_date FROM transactions")
462
            .fetch_one(&mut *conn)
463
            .await
464
            .unwrap();
465

            
466
        assert_eq!(transaction.id, result.id);
467
    }
468

            
469
    #[sqlx::test(migrations = "../migrations")]
470
    async fn test_transaction_builer(pool: PgPool) -> anyhow::Result<()> {
471
        setup(&pool).await;
472
        let build = Transaction::builder().id(Uuid::new_v4()).build();
473
        assert!(build.is_err());
474

            
475
        let build = Transaction::builder()
476
            .id(Uuid::new_v4())
477
            .post_date(Local::now().into())
478
            .enter_date(Local::now().into())
479
            .build();
480
        assert!(build.is_ok());
481

            
482
        Ok(())
483
    }
484

            
485
    #[sqlx::test(migrations = "../migrations")]
486
    async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
487
        setup(&pool).await;
488

            
489
        let tx = Transaction::builder()
490
            .id(Uuid::new_v4())
491
            .post_date(Local::now().into())
492
            .enter_date(Local::now().into())
493
            .build()?;
494

            
495
        let mut conn = pool.acquire().await?;
496
        let mut tr = tx.enter(&mut *conn).await?;
497
        tr.rollback().await?;
498
        let mut conn = pool.acquire().await?;
499
        assert!(
500
            sqlx::query!("SELECT id, post_date FROM transactions")
501
                .fetch_one(&mut *conn)
502
                .await
503
                .is_err()
504
        );
505

            
506
        let mut conn = pool.acquire().await?;
507
        let mut tr = tx.enter(&mut *conn).await?;
508
        assert!(tr.commit().await.is_err()); // Erroneous split
509

            
510
        Ok(())
511
    }
512

            
513
    /// Regression: two transactions tagged with the same
514
    /// `(tag_name, tag_value)` pair must both link to the canonical
515
    /// row. Before the fix `add_tags` linked via the in-memory
516
    /// `Tag.id`, so the second call tripped
517
    /// `transaction_tags_tag_id_fkey` because the canonical row's
518
    /// id was returned by `Tag::commit` but discarded. Same shape
519
    /// for `add_split_tags` — covered as well.
520
    #[sqlx::test(migrations = "../migrations")]
521
    async fn test_add_tags_uses_canonical_id(pool: PgPool) -> anyhow::Result<()> {
522
        setup(&pool).await;
523

            
524
        let tag_template = || Tag {
525
4
            id: Uuid::new_v4(),
526
4
            tag_name: "category".to_string(),
527
4
            tag_value: "groceries".to_string(),
528
4
            description: None,
529
4
        };
530

            
531
        for value in [-100i64, -200] {
532
            let tx = Transaction::builder()
533
                .id(Uuid::new_v4())
534
                .post_date(Local::now().into())
535
                .enter_date(Local::now().into())
536
                .build()?;
537

            
538
            let mut conn = pool.acquire().await?;
539
            let mut tr = tx.enter(&mut *conn).await?;
540
            let split_spend = SplitBuilder::new()
541
                .account_id(WALLET.get().unwrap().id)
542
                .commodity_id(COMMODITY.get().unwrap().id)
543
                .id(Uuid::new_v4())
544
                .value_num(value)
545
                .value_denom(1)
546
                .tx_id(tx.id)
547
                .build()?;
548
            let split_purchase = SplitBuilder::new()
549
                .account_id(SHOP.get().unwrap().id)
550
                .commodity_id(COMMODITY.get().unwrap().id)
551
                .id(Uuid::new_v4())
552
                .value_num(-value)
553
                .value_denom(1)
554
                .tx_id(tx.id)
555
                .build()?;
556
            tr.add_splits(&[&split_spend, &split_purchase]).await?;
557

            
558
            let tx_tag = tag_template();
559
            tr.add_tags(&[&tx_tag]).await?;
560
            tr.add_split_tags(&[(split_spend.id, tag_template())])
561
                .await?;
562
            tr.commit().await?;
563
        }
564

            
565
        let mut conn = pool.acquire().await?;
566
        let tag_count: i64 =
567
            sqlx::query_scalar("SELECT COUNT(*) FROM tags WHERE tag_name = 'category'")
568
                .fetch_one(&mut *conn)
569
                .await?;
570
        assert_eq!(tag_count, 1, "tag rows must be canonical");
571
        let link_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transaction_tags")
572
            .fetch_one(&mut *conn)
573
            .await?;
574
        assert_eq!(
575
            link_count, 2,
576
            "both transactions linked to the canonical tag"
577
        );
578
        let split_link_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM split_tags")
579
            .fetch_one(&mut *conn)
580
            .await?;
581
        assert_eq!(
582
            split_link_count, 2,
583
            "both splits linked to the canonical tag"
584
        );
585

            
586
        Ok(())
587
    }
588

            
589
    #[sqlx::test(migrations = "../migrations")]
590
    async fn test_transaction_balance(pool: PgPool) -> anyhow::Result<()> {
591
        setup(&pool).await;
592

            
593
        let tx = Transaction::builder()
594
            .id(Uuid::new_v4())
595
            .post_date(Local::now().into())
596
            .enter_date(Local::now().into())
597
            .build()?;
598

            
599
        let mut conn = pool.acquire().await?;
600
        let mut tr = tx.enter(&mut *conn).await?;
601
        let split_spend = SplitBuilder::new()
602
            .account_id(WALLET.get().unwrap().id)
603
            .commodity_id(COMMODITY.get().unwrap().id)
604
            .id(Uuid::new_v4())
605
            .value_num(-100)
606
            .value_denom(1)
607
            .tx_id(tx.id)
608
            .build()?;
609

            
610
        let split_purchase = SplitBuilder::new()
611
            .account_id(SHOP.get().unwrap().id)
612
            .commodity_id(COMMODITY.get().unwrap().id)
613
            .id(Uuid::new_v4())
614
            .value_num(100)
615
            .value_denom(1)
616
            .tx_id(tx.id)
617
            .build()?;
618

            
619
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
620
        assert!(tr.commit().await.is_ok());
621

            
622
        let tx = Transaction::builder()
623
            .id(Uuid::new_v4())
624
            .post_date(Local::now().into())
625
            .enter_date(Local::now().into())
626
            .build()?;
627

            
628
        let mut conn = pool.acquire().await?;
629
        let mut tr = tx.enter(&mut *conn).await?;
630
        let split_spend = SplitBuilder::new()
631
            .account_id(WALLET.get().unwrap().id)
632
            .commodity_id(COMMODITY.get().unwrap().id)
633
            .id(Uuid::new_v4())
634
            .value_num(-100)
635
            .value_denom(1)
636
            .tx_id(tx.id)
637
            .build()?;
638

            
639
        let split_purchase = SplitBuilder::new()
640
            .account_id(SHOP.get().unwrap().id)
641
            .commodity_id(COMMODITY.get().unwrap().id)
642
            .id(Uuid::new_v4())
643
            .value_num(99)
644
            .value_denom(1)
645
            .tx_id(tx.id)
646
            .build()?;
647

            
648
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
649
        assert!(tr.commit().await.is_err());
650

            
651
        Ok(())
652
    }
653

            
654
    #[sqlx::test(migrations = "../migrations")]
655
    async fn test_transaction_multicurrency(pool: PgPool) -> anyhow::Result<()> {
656
        setup(&pool).await;
657

            
658
        let tx = Transaction::builder()
659
            .id(Uuid::new_v4())
660
            .post_date(Local::now().into())
661
            .enter_date(Local::now().into())
662
            .build()?;
663

            
664
        let mut conn = pool.acquire().await?;
665
        let mut tr = tx.enter(&mut *conn).await?;
666
        let split_spend = SplitBuilder::new()
667
            .account_id(WALLET.get().unwrap().id)
668
            .commodity_id(COMMODITY.get().unwrap().id)
669
            .id(Uuid::new_v4())
670
            .value_num(-1000)
671
            .value_denom(1)
672
            .tx_id(tx.id)
673
            .build()?;
674

            
675
        let split_purchase = SplitBuilder::new()
676
            .account_id(ONLINE_SHOP.get().unwrap().id)
677
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
678
            .id(Uuid::new_v4())
679
            .value_num(7)
680
            .value_denom(1)
681
            .tx_id(tx.id)
682
            .build()?;
683

            
684
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
685

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

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

            
699
        assert!(tr.commit().await.is_ok());
700

            
701
        Ok(())
702
    }
703

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

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

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

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

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

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

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

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

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

            
772
        tr.add_conversions(&[&conversion]).await?;
773

            
774
        assert!(tr.commit().await.is_err()); // No second conversion
775

            
776
        Ok(())
777
    }
778

            
779
    #[sqlx::test(migrations = "../migrations")]
780
    async fn test_transaction_multicurrency_2(pool: PgPool) -> anyhow::Result<()> {
781
        setup(&pool).await;
782

            
783
        let tx = Transaction::builder()
784
            .id(Uuid::new_v4())
785
            .post_date(Local::now().into())
786
            .enter_date(Local::now().into())
787
            .build()?;
788

            
789
        let mut conn = pool.acquire().await?;
790
        let mut tr = tx.enter(&mut *conn).await?;
791

            
792
        let split_spend = SplitBuilder::new()
793
            .account_id(WALLET.get().unwrap().id)
794
            .commodity_id(COMMODITY.get().unwrap().id)
795
            .id(Uuid::new_v4())
796
            .value_num(-1000)
797
            .value_denom(1)
798
            .tx_id(tx.id)
799
            .build()?;
800

            
801
        let split_purchase = SplitBuilder::new()
802
            .account_id(ONLINE_SHOP.get().unwrap().id)
803
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
804
            .id(Uuid::new_v4())
805
            .value_num(7)
806
            .value_denom(1)
807
            .tx_id(tx.id)
808
            .build()?;
809

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

            
819
        let split_purchase_2 = SplitBuilder::new()
820
            .account_id(ONLINE_SHOP_2.get().unwrap().id)
821
            .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
822
            .id(Uuid::new_v4())
823
            .value_num(10)
824
            .value_denom(1)
825
            .tx_id(tx.id)
826
            .build()?;
827

            
828
        tr.add_splits(&[
829
            &split_spend,
830
            &split_purchase,
831
            &split_spend_2,
832
            &split_purchase_2,
833
        ])
834
        .await?;
835

            
836
        let conversion = Price {
837
            id: Uuid::new_v4(),
838
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
839
            currency_id: COMMODITY.get().unwrap().id,
840
            commodity_split: Some(split_purchase.id),
841
            currency_split: Some(split_spend.id),
842
            date: Local::now().into(),
843
            value_num: 1000,
844
            value_denom: 7,
845
        };
846

            
847
        let conversion_2 = Price {
848
            id: Uuid::new_v4(),
849
            commodity_id: FOREIGN_COMMODITY_2.get().unwrap().id,
850
            currency_id: COMMODITY.get().unwrap().id,
851
            commodity_split: Some(split_purchase_2.id),
852
            currency_split: Some(split_spend_2.id),
853
            date: Local::now().into(),
854
            value_num: 1000,
855
            value_denom: 10,
856
        };
857

            
858
        tr.add_conversions(&[&conversion, &conversion_2]).await?;
859

            
860
        assert!(tr.commit().await.is_ok());
861

            
862
        Ok(())
863
    }
864

            
865
    #[sqlx::test(migrations = "../migrations")]
866
    async fn test_transaction_unbalanced(pool: PgPool) -> anyhow::Result<()> {
867
        setup(&pool).await;
868

            
869
        let tx = Transaction::builder()
870
            .id(Uuid::new_v4())
871
            .post_date(Local::now().into())
872
            .enter_date(Local::now().into())
873
            .build()?;
874

            
875
        let mut conn = pool.acquire().await?;
876
        let mut tr = tx.enter(&mut *conn).await?;
877
        let split_spend = SplitBuilder::new()
878
            .account_id(WALLET.get().unwrap().id)
879
            .commodity_id(COMMODITY.get().unwrap().id)
880
            .id(Uuid::new_v4())
881
            .value_num(-100)
882
            .value_denom(1)
883
            .tx_id(tx.id)
884
            .build()?;
885

            
886
        let split_purchase = SplitBuilder::new()
887
            .account_id(SHOP.get().unwrap().id)
888
            .commodity_id(COMMODITY.get().unwrap().id)
889
            .id(Uuid::new_v4())
890
            .value_num(90)
891
            .value_denom(1)
892
            .tx_id(tx.id)
893
            .build()?;
894

            
895
        tr.add_splits(&[&split_spend, &split_purchase]).await?;
896
        assert!(tr.commit().await.is_err());
897

            
898
        Ok(())
899
    }
900

            
901
    #[sqlx::test(migrations = "../migrations")]
902
    async fn test_transaction_multicurrency_jpy_usd(pool: PgPool) -> anyhow::Result<()> {
903
        setup(&pool).await;
904

            
905
        let tx = Transaction::builder()
906
            .id(Uuid::new_v4())
907
            .post_date(Local::now().into())
908
            .enter_date(Local::now().into())
909
            .build()?;
910

            
911
        let mut conn = pool.acquire().await?;
912
        let mut tr = tx.enter(&mut *conn).await?;
913

            
914
        // Spend 25584 JPY (denom=1 since JPY has no fractional part)
915
        let split_spend = SplitBuilder::new()
916
            .account_id(WALLET.get().unwrap().id)
917
            .commodity_id(COMMODITY.get().unwrap().id)
918
            .id(Uuid::new_v4())
919
            .value_num(-25584)
920
            .value_denom(1)
921
            .tx_id(tx.id)
922
            .build()?;
923

            
924
        // Receive 153.81 USD = 15381/100
925
        let split_receive = SplitBuilder::new()
926
            .account_id(ONLINE_SHOP.get().unwrap().id)
927
            .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
928
            .id(Uuid::new_v4())
929
            .value_num(15381)
930
            .value_denom(100)
931
            .tx_id(tx.id)
932
            .build()?;
933

            
934
        tr.add_splits(&[&split_spend, &split_receive]).await?;
935

            
936
        // Rate: 1 USD = 25584/153.81 JPY = 2558400/15381 JPY
937
        // Verification: 15381/100 * 2558400/15381 = 2558400/100 = 25584
938
        // So transaction_sum = -25584 + 25584 = 0
939
        let conversion = Price {
940
            id: Uuid::new_v4(),
941
            commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
942
            currency_id: COMMODITY.get().unwrap().id,
943
            commodity_split: Some(split_receive.id),
944
            currency_split: Some(split_spend.id),
945
            date: Local::now().into(),
946
            value_num: 2558400,
947
            value_denom: 15381,
948
        };
949

            
950
        tr.add_conversions(&[&conversion]).await?;
951

            
952
        assert!(tr.commit().await.is_ok());
953

            
954
        Ok(())
955
    }
956
}