1use crate::error::{FinanceError, TransactionError};
2use crate::price::Price;
3use crate::split::Split;
4use crate::tag::Tag;
5use itertools::Itertools;
6use num_rational::Rational64;
7use sqlx::types::Uuid;
8use sqlx::types::chrono::{DateTime, Utc};
9use sqlx::{Connection, Postgres, query_file};
10use std::collections::HashMap;
11use supp_macro::Builder;
12
13#[derive(Debug, sqlx::FromRow, Builder)]
26#[builder(error_kind = "FinanceError")]
27pub struct Transaction {
28 pub id: Uuid,
29 pub post_date: DateTime<Utc>,
30 pub enter_date: DateTime<Utc>,
31}
32
33pub struct TransactionTicket<'t, 's> {
43 sqltx: Option<sqlx::Transaction<'s, Postgres>>,
44 tx: &'t Transaction,
45}
46
47impl<'t> TransactionTicket<'t, '_> {
48 pub async fn commit(&mut self) -> Result<&'t Transaction, FinanceError> {
57 if let Some(mut sqltx) = self.sqltx.take() {
58 let splits = query_file!("sql/transaction_select_splits.sql", &self.tx.id)
59 .fetch_all(&mut *sqltx)
60 .await?;
61
62 let distinct_commodities: Vec<Uuid> =
63 splits.iter().map(|s| s.commodity_id).unique().collect();
64
65 if distinct_commodities.is_empty() {
66 return Err(FinanceError::Internal(
67 t!("No splits found for this transaction").to_string(),
68 ));
69 }
70
71 if distinct_commodities.len() == 1 {
72 let sum_splits = splits
73 .iter()
74 .map(|s| Rational64::new(s.value_num, s.value_denom))
75 .reduce(|a, b| a + b)
76 .ok_or_else(|| FinanceError::Internal(t!("Erroneous split").to_string()))?;
77
78 if sum_splits != 0.into() {
79 return Err(FinanceError::Transaction(TransactionError::Build(
80 t!("Unbalanced Transaction: sum of splits is non-zero").to_string(),
81 )));
82 }
83 } else {
84 let base_commodity = distinct_commodities[0];
87
88 let mut splits_by_commodity: HashMap<Uuid, Vec<_>> = HashMap::new();
90 for s in &splits {
91 splits_by_commodity
92 .entry(s.commodity_id)
93 .or_default()
94 .push(s);
95 }
96
97 let mut transaction_sum = Rational64::from_integer(0);
98
99 if let Some(base_splits) = splits_by_commodity.get(&base_commodity) {
101 for s in base_splits {
102 transaction_sum += Rational64::new(s.value_num, s.value_denom);
103 }
104 } else {
105 return Err(FinanceError::Internal(format!(
106 "{}",
107 t!("Internal error: multi-currency transaction is inconsistent")
108 )));
109 }
110
111 for (commodity_id, split_group) in &splits_by_commodity {
113 if *commodity_id == base_commodity {
114 continue;
116 }
117
118 let split_ids: Vec<Uuid> = split_group.iter().map(|s| s.id).collect();
119 let prices =
120 query_file!("sql/transaction_select_prices_by_splits.sql", &split_ids)
121 .fetch_all(&mut *sqltx)
122 .await?;
123
124 if prices.is_empty() {
125 return Err(FinanceError::Internal(format!(
126 "{} {}",
127 t!("No price records found for commodity: "),
128 commodity_id
129 )));
130 }
131
132 let mut price_map: HashMap<Uuid, Vec<_>> = HashMap::new();
134 for p in prices {
135 if let Some(cid) = p.commodity_split_id {
137 price_map.entry(cid).or_default().push(p);
138 } else if let Some(cid) = p.currency_split_id {
139 price_map.entry(cid).or_default().push(p);
141 }
142 }
143
144 for s in split_group {
145 let split_val = Rational64::new(s.value_num, s.value_denom);
146
147 let split_price = price_map.get(&s.id).ok_or_else(|| {
148 FinanceError::Internal(format!(
149 "{} {}",
150 t!("Price not found for split"),
151 s.id
152 ))
153 })?;
154
155 let price = split_price.first().ok_or_else(|| {
156 FinanceError::Internal(format!(
157 "{} {}",
158 t!("Price not found for split"),
159 s.id
160 ))
161 })?;
162
163 let conv_rate = Rational64::new(price.value_num, price.value_denom);
164
165 let sum_converted = if price.commodity_split_id == Some(s.id) {
166 split_val * conv_rate
168 } else {
169 split_val * conv_rate.recip()
171 };
172
173 transaction_sum += sum_converted;
174 }
175 }
176
177 if transaction_sum != 0.into() {
178 return Err(FinanceError::Transaction(TransactionError::Build(
179 t!("Unbalanced Transaction after conversion: sum != 0").to_string(),
180 )));
181 }
182 }
183
184 sqltx.commit().await?;
185 Ok(self.tx)
186 } else {
187 Err(FinanceError::Internal(
188 t!("Attempt to commit the empty ticket").to_string(),
189 ))
190 }
191 }
192
193 pub async fn rollback(&mut self) -> Result<&'t Transaction, FinanceError> {
201 if let Some(sqltx) = self.sqltx.take() {
202 sqltx.rollback().await?;
203 Ok(self.tx)
204 } else {
205 Err(FinanceError::Internal(
206 t!("Attempt to rollback the empty ticket").to_string(),
207 ))
208 }
209 }
210
211 pub async fn add_splits(&mut self, splits: &[&Split]) -> Result<&'t Transaction, FinanceError> {
212 if let Some(sqltx) = &mut self.sqltx {
213 for s in splits {
214 if s.tx_id != self.tx.id {
215 return Err(FinanceError::Transaction(TransactionError::WrongSplit(
216 t!("Attempt to apply split from another transaction").to_string(),
217 )));
218 }
219 query_file!(
220 "sql/split_insert.sql",
221 &s.id,
222 &s.tx_id,
223 &s.account_id,
224 &s.commodity_id,
225 s.reconcile_state,
226 s.reconcile_date,
227 &s.value_num,
228 &s.value_denom,
229 s.lot_id
230 )
231 .execute(&mut **sqltx)
232 .await?;
233 }
234 Ok(self.tx)
235 } else {
236 Err(FinanceError::Internal(
237 t!("Adding splits failed").to_string(),
238 ))
239 }
240 }
241
242 pub async fn add_conversions(
243 &mut self,
244 prices: &[&Price],
245 ) -> Result<&'t Transaction, FinanceError> {
246 if let Some(sqltx) = &mut self.sqltx {
247 for p in prices {
248 p.commit(&mut **sqltx).await?;
249 }
250 Ok(self.tx)
251 } else {
252 Err(FinanceError::Internal(
253 t!("Adding conversions failed").to_string(),
254 ))
255 }
256 }
257
258 pub async fn add_tags(&mut self, tags: &[&Tag]) -> Result<&'t Transaction, FinanceError> {
259 if let Some(sqltx) = &mut self.sqltx {
260 for t in tags {
261 let tag_id = t.commit(&mut **sqltx).await?;
262 query_file!("sql/transaction_tag_set.sql", self.tx.id, tag_id)
263 .execute(&mut **sqltx)
264 .await?;
265 }
266 Ok(self.tx)
267 } else {
268 Err(FinanceError::Internal(t!("Adding tags failed").to_string()))
269 }
270 }
271
272 pub async fn add_split_tags(
273 &mut self,
274 split_tags: &[(Uuid, Tag)],
275 ) -> Result<&'t Transaction, FinanceError> {
276 if let Some(sqltx) = &mut self.sqltx {
277 for (split_id, tag) in split_tags {
278 let tag_id = tag.commit(&mut **sqltx).await?;
279 query_file!("sql/split_tag_set.sql", split_id, tag_id)
280 .execute(&mut **sqltx)
281 .await?;
282 }
283 Ok(self.tx)
284 } else {
285 Err(FinanceError::Internal(
286 t!("Adding split tags failed").to_string(),
287 ))
288 }
289 }
290
291 pub async fn execute<F, Fut, T>(&mut self, f: F) -> Result<T, FinanceError>
296 where
297 F: FnOnce(&mut sqlx::PgConnection) -> Fut,
298 Fut: std::future::Future<Output = Result<T, sqlx::Error>>,
299 {
300 if let Some(sqltx) = &mut self.sqltx {
301 f(sqltx).await.map_err(Into::into)
302 } else {
303 Err(FinanceError::Internal(
304 t!("Cannot execute on empty ticket").to_string(),
305 ))
306 }
307 }
308}
309
310impl<'t> Transaction {
311 pub async fn enter<'p, E>(
327 &'t self,
328 conn: &'p mut E,
329 ) -> Result<TransactionTicket<'t, 'p>, FinanceError>
330 where
331 E: Connection<Database = sqlx::Postgres>,
332 {
333 let mut tr = conn.begin().await?;
334
335 sqlx::query_file!(
336 "sql/transaction_insert.sql",
337 &self.id,
338 &self.post_date,
339 &self.enter_date
340 )
341 .execute(&mut *tr)
342 .await?;
343
344 Ok(TransactionTicket {
345 sqltx: Some(tr),
346 tx: self,
347 })
348 }
349}
350
351#[cfg(test)]
352mod transaction_tests {
353 use super::*;
354 use crate::account::{Account, AccountBuilder};
355 use crate::commodity::{Commodity, CommodityBuilder};
356 use crate::split::SplitBuilder;
357 #[cfg(feature = "testlog")]
358 use env_logger;
359 #[cfg(feature = "testlog")]
360 use log;
361 use sqlx::PgPool;
362 use sqlx::types::chrono::Local;
363 use tokio::sync::OnceCell;
364
365 static CONTEXT: OnceCell<()> = OnceCell::const_new();
367 static COMMODITY: OnceCell<Commodity> = OnceCell::const_new();
368 static WALLET: OnceCell<Account> = OnceCell::const_new();
369 static SHOP: OnceCell<Account> = OnceCell::const_new();
370
371 static FOREIGN_COMMODITY: OnceCell<Commodity> = OnceCell::const_new();
372 static ONLINE_SHOP: OnceCell<Account> = OnceCell::const_new();
373
374 static FOREIGN_COMMODITY_2: OnceCell<Commodity> = OnceCell::const_new();
375 static ONLINE_SHOP_2: OnceCell<Account> = OnceCell::const_new();
376
377 async fn setup(pool: &PgPool) {
378 let mut conn = pool.acquire().await.unwrap();
379
380 CONTEXT
381 .get_or_init(|| async {
382 #[cfg(feature = "testlog")]
383 let _ = env_logger::builder()
384 .is_test(true)
385 .filter_level(log::LevelFilter::Trace)
386 .try_init();
387 })
388 .await;
389
390 COMMODITY
391 .get_or_init(|| async { CommodityBuilder::new().id(Uuid::new_v4()).build().unwrap() })
392 .await;
393 COMMODITY.get().unwrap().commit(&mut *conn).await.unwrap();
394
395 WALLET
396 .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
397 .await;
398 WALLET.get().unwrap().commit(&mut *conn).await.unwrap();
399
400 SHOP.get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
401 .await;
402 SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
403
404 FOREIGN_COMMODITY
405 .get_or_init(|| async { CommodityBuilder::new().id(Uuid::new_v4()).build().unwrap() })
406 .await;
407 FOREIGN_COMMODITY
408 .get()
409 .unwrap()
410 .commit(&mut *conn)
411 .await
412 .unwrap();
413
414 ONLINE_SHOP
415 .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
416 .await;
417 ONLINE_SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
418
419 FOREIGN_COMMODITY_2
420 .get_or_init(|| async { CommodityBuilder::new().id(Uuid::new_v4()).build().unwrap() })
421 .await;
422 FOREIGN_COMMODITY_2
423 .get()
424 .unwrap()
425 .commit(&mut *conn)
426 .await
427 .unwrap();
428
429 ONLINE_SHOP_2
430 .get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
431 .await;
432 ONLINE_SHOP_2
433 .get()
434 .unwrap()
435 .commit(&mut *conn)
436 .await
437 .unwrap();
438 }
439
440 #[sqlx::test(migrations = "../migrations")]
441 async fn test_transaction_store(pool: PgPool) {
442 setup(&pool).await;
443 let mut conn = pool.acquire().await.unwrap();
444
445 let transaction = Transaction {
446 id: Uuid::new_v4(),
447 post_date: Local::now().into(),
448 enter_date: Local::now().into(),
449 };
450
451 sqlx::query_file!(
452 "sql/transaction_insert.sql",
453 &transaction.id,
454 &transaction.post_date,
455 &transaction.enter_date
456 )
457 .execute(&mut *conn)
458 .await
459 .unwrap();
460
461 let result = sqlx::query!("SELECT id, post_date FROM transactions")
462 .fetch_one(&mut *conn)
463 .await
464 .unwrap();
465
466 assert_eq!(transaction.id, result.id);
467 }
468
469 #[sqlx::test(migrations = "../migrations")]
470 async fn test_transaction_builer(pool: PgPool) -> anyhow::Result<()> {
471 setup(&pool).await;
472 let build = Transaction::builder().id(Uuid::new_v4()).build();
473 assert!(build.is_err());
474
475 let build = Transaction::builder()
476 .id(Uuid::new_v4())
477 .post_date(Local::now().into())
478 .enter_date(Local::now().into())
479 .build();
480 assert!(build.is_ok());
481
482 Ok(())
483 }
484
485 #[sqlx::test(migrations = "../migrations")]
486 async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
487 setup(&pool).await;
488
489 let tx = Transaction::builder()
490 .id(Uuid::new_v4())
491 .post_date(Local::now().into())
492 .enter_date(Local::now().into())
493 .build()?;
494
495 let mut conn = pool.acquire().await?;
496 let mut tr = tx.enter(&mut *conn).await?;
497 tr.rollback().await?;
498 let mut conn = pool.acquire().await?;
499 assert!(
500 sqlx::query!("SELECT id, post_date FROM transactions")
501 .fetch_one(&mut *conn)
502 .await
503 .is_err()
504 );
505
506 let mut conn = pool.acquire().await?;
507 let mut tr = tx.enter(&mut *conn).await?;
508 assert!(tr.commit().await.is_err()); Ok(())
511 }
512
513 #[sqlx::test(migrations = "../migrations")]
521 async fn test_add_tags_uses_canonical_id(pool: PgPool) -> anyhow::Result<()> {
522 setup(&pool).await;
523
524 let tag_template = || Tag {
525 id: Uuid::new_v4(),
526 tag_name: "category".to_string(),
527 tag_value: "groceries".to_string(),
528 description: None,
529 };
530
531 for value in [-100i64, -200] {
532 let tx = Transaction::builder()
533 .id(Uuid::new_v4())
534 .post_date(Local::now().into())
535 .enter_date(Local::now().into())
536 .build()?;
537
538 let mut conn = pool.acquire().await?;
539 let mut tr = tx.enter(&mut *conn).await?;
540 let split_spend = SplitBuilder::new()
541 .account_id(WALLET.get().unwrap().id)
542 .commodity_id(COMMODITY.get().unwrap().id)
543 .id(Uuid::new_v4())
544 .value_num(value)
545 .value_denom(1)
546 .tx_id(tx.id)
547 .build()?;
548 let split_purchase = SplitBuilder::new()
549 .account_id(SHOP.get().unwrap().id)
550 .commodity_id(COMMODITY.get().unwrap().id)
551 .id(Uuid::new_v4())
552 .value_num(-value)
553 .value_denom(1)
554 .tx_id(tx.id)
555 .build()?;
556 tr.add_splits(&[&split_spend, &split_purchase]).await?;
557
558 let tx_tag = tag_template();
559 tr.add_tags(&[&tx_tag]).await?;
560 tr.add_split_tags(&[(split_spend.id, tag_template())])
561 .await?;
562 tr.commit().await?;
563 }
564
565 let mut conn = pool.acquire().await?;
566 let tag_count: i64 =
567 sqlx::query_scalar("SELECT COUNT(*) FROM tags WHERE tag_name = 'category'")
568 .fetch_one(&mut *conn)
569 .await?;
570 assert_eq!(tag_count, 1, "tag rows must be canonical");
571 let link_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transaction_tags")
572 .fetch_one(&mut *conn)
573 .await?;
574 assert_eq!(
575 link_count, 2,
576 "both transactions linked to the canonical tag"
577 );
578 let split_link_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM split_tags")
579 .fetch_one(&mut *conn)
580 .await?;
581 assert_eq!(
582 split_link_count, 2,
583 "both splits linked to the canonical tag"
584 );
585
586 Ok(())
587 }
588
589 #[sqlx::test(migrations = "../migrations")]
590 async fn test_transaction_balance(pool: PgPool) -> anyhow::Result<()> {
591 setup(&pool).await;
592
593 let tx = Transaction::builder()
594 .id(Uuid::new_v4())
595 .post_date(Local::now().into())
596 .enter_date(Local::now().into())
597 .build()?;
598
599 let mut conn = pool.acquire().await?;
600 let mut tr = tx.enter(&mut *conn).await?;
601 let split_spend = SplitBuilder::new()
602 .account_id(WALLET.get().unwrap().id)
603 .commodity_id(COMMODITY.get().unwrap().id)
604 .id(Uuid::new_v4())
605 .value_num(-100)
606 .value_denom(1)
607 .tx_id(tx.id)
608 .build()?;
609
610 let split_purchase = SplitBuilder::new()
611 .account_id(SHOP.get().unwrap().id)
612 .commodity_id(COMMODITY.get().unwrap().id)
613 .id(Uuid::new_v4())
614 .value_num(100)
615 .value_denom(1)
616 .tx_id(tx.id)
617 .build()?;
618
619 tr.add_splits(&[&split_spend, &split_purchase]).await?;
620 assert!(tr.commit().await.is_ok());
621
622 let tx = Transaction::builder()
623 .id(Uuid::new_v4())
624 .post_date(Local::now().into())
625 .enter_date(Local::now().into())
626 .build()?;
627
628 let mut conn = pool.acquire().await?;
629 let mut tr = tx.enter(&mut *conn).await?;
630 let split_spend = SplitBuilder::new()
631 .account_id(WALLET.get().unwrap().id)
632 .commodity_id(COMMODITY.get().unwrap().id)
633 .id(Uuid::new_v4())
634 .value_num(-100)
635 .value_denom(1)
636 .tx_id(tx.id)
637 .build()?;
638
639 let split_purchase = SplitBuilder::new()
640 .account_id(SHOP.get().unwrap().id)
641 .commodity_id(COMMODITY.get().unwrap().id)
642 .id(Uuid::new_v4())
643 .value_num(99)
644 .value_denom(1)
645 .tx_id(tx.id)
646 .build()?;
647
648 tr.add_splits(&[&split_spend, &split_purchase]).await?;
649 assert!(tr.commit().await.is_err());
650
651 Ok(())
652 }
653
654 #[sqlx::test(migrations = "../migrations")]
655 async fn test_transaction_multicurrency(pool: PgPool) -> anyhow::Result<()> {
656 setup(&pool).await;
657
658 let tx = Transaction::builder()
659 .id(Uuid::new_v4())
660 .post_date(Local::now().into())
661 .enter_date(Local::now().into())
662 .build()?;
663
664 let mut conn = pool.acquire().await?;
665 let mut tr = tx.enter(&mut *conn).await?;
666 let split_spend = SplitBuilder::new()
667 .account_id(WALLET.get().unwrap().id)
668 .commodity_id(COMMODITY.get().unwrap().id)
669 .id(Uuid::new_v4())
670 .value_num(-1000)
671 .value_denom(1)
672 .tx_id(tx.id)
673 .build()?;
674
675 let split_purchase = SplitBuilder::new()
676 .account_id(ONLINE_SHOP.get().unwrap().id)
677 .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
678 .id(Uuid::new_v4())
679 .value_num(7)
680 .value_denom(1)
681 .tx_id(tx.id)
682 .build()?;
683
684 tr.add_splits(&[&split_spend, &split_purchase]).await?;
685
686 let conversion = Price {
687 id: Uuid::new_v4(),
688 commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
689 currency_id: COMMODITY.get().unwrap().id,
690 commodity_split: Some(split_purchase.id),
691 currency_split: Some(split_spend.id),
692 date: Local::now().into(),
693 value_num: 1000,
694 value_denom: 7,
695 };
696
697 tr.add_conversions(&[&conversion]).await?;
698
699 assert!(tr.commit().await.is_ok());
700
701 Ok(())
702 }
703
704 #[sqlx::test(migrations = "../migrations")]
705 async fn test_transaction_multicurrency_fail(pool: PgPool) -> anyhow::Result<()> {
706 setup(&pool).await;
707
708 let tx = Transaction::builder()
709 .id(Uuid::new_v4())
710 .post_date(Local::now().into())
711 .enter_date(Local::now().into())
712 .build()?;
713
714 let mut conn = pool.acquire().await?;
715 let mut tr = tx.enter(&mut *conn).await?;
716
717 let split_spend = SplitBuilder::new()
718 .account_id(WALLET.get().unwrap().id)
719 .commodity_id(COMMODITY.get().unwrap().id)
720 .id(Uuid::new_v4())
721 .value_num(-1000)
722 .value_denom(1)
723 .tx_id(tx.id)
724 .build()?;
725
726 let split_purchase = SplitBuilder::new()
727 .account_id(ONLINE_SHOP.get().unwrap().id)
728 .commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
729 .id(Uuid::new_v4())
730 .value_num(7)
731 .value_denom(1)
732 .tx_id(tx.id)
733 .build()?;
734
735 let split_spend_2 = SplitBuilder::new()
736 .account_id(WALLET.get().unwrap().id)
737 .commodity_id(COMMODITY.get().unwrap().id)
738 .id(Uuid::new_v4())
739 .value_num(-1000)
740 .value_denom(1)
741 .tx_id(tx.id)
742 .build()?;
743
744 let split_purchase_2 = SplitBuilder::new()
745 .account_id(ONLINE_SHOP_2.get().unwrap().id)
746 .commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
747 .id(Uuid::new_v4())
748 .value_num(10)
749 .value_denom(1)
750 .tx_id(tx.id)
751 .build()?;
752
753 tr.add_splits(&[
754 &split_spend,
755 &split_purchase,
756 &split_spend_2,
757 &split_purchase_2,
758 ])
759 .await?;
760
761 let conversion = Price {
762 id: Uuid::new_v4(),
763 commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
764 currency_id: COMMODITY.get().unwrap().id,
765 commodity_split: Some(split_purchase.id),
766 currency_split: Some(split_spend.id),
767 date: Local::now().into(),
768 value_num: 1000,
769 value_denom: 7,
770 };
771
772 tr.add_conversions(&[&conversion]).await?;
773
774 assert!(tr.commit().await.is_err()); 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 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 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 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}