Lines
89.32 %
Functions
26.75 %
Branches
100 %
use crate::error::{FinanceError, TransactionError};
use crate::price::Price;
use crate::split::Split;
use crate::tag::Tag;
use itertools::Itertools;
use num_rational::Rational64;
use sqlx::types::Uuid;
use sqlx::types::chrono::{DateTime, Utc};
use sqlx::{Connection, Postgres, query_file};
use std::collections::HashMap;
use supp_macro::Builder;
/// The Transaction which is a start point of all the accounting.
///
/// A `Transaction` keeps id of a financial event and is used to group `Split`s
/// using all the features provided.
/// # Attributes
/// - `id`: A unique identifier for the transaction.
/// - `commodity`: A reference to the `Commodity` associated of this transaction.
/// - `num`: An optional number or code associated with the transaction.
/// - `post_date`: The date the transaction is performed.
/// - `enter_date`: The date the transaction is entered.
/// - `description`: An optional description of the transaction.
#[derive(Debug, sqlx::FromRow, Builder)]
#[builder(error_kind = "FinanceError")]
pub struct Transaction {
pub id: Uuid,
pub post_date: DateTime<Utc>,
pub enter_date: DateTime<Utc>,
}
/// An active ticket for a semi-entered transaction.
/// A `TransactionTicket` manages the lifecycle of the `Transaction`, allowing
/// for explicit commit or rollback operations. It ensures that any changes
/// made within the transaction are properly finalized or reverted.
/// # Generic Lifetimes
/// - `'t`: Lifetime of the referenced `Transaction`.
/// - `'s`: Lifetime of the `SQLx` transaction.
pub struct TransactionTicket<'t, 's> {
sqltx: Option<sqlx::Transaction<'s, Postgres>>,
tx: &'t Transaction,
impl<'t> TransactionTicket<'t, '_> {
/// Validates the inputs and commits the transaction, finalizing all
/// changes.
/// # Returns
/// A reference to the original `Transaction` if the commit is successful.
/// # Errors
/// Returns a `FinanceError` if the ticket is empty or the commit operation fails.
pub async fn commit(&mut self) -> Result<&'t Transaction, FinanceError> {
if let Some(mut sqltx) = self.sqltx.take() {
let splits = query_file!("sql/transaction_select_splits.sql", &self.tx.id)
.fetch_all(&mut *sqltx)
.await?;
let distinct_commodities: Vec<Uuid> =
splits.iter().map(|s| s.commodity_id).unique().collect();
if distinct_commodities.is_empty() {
return Err(FinanceError::Internal(
t!("No splits found for this transaction").to_string(),
));
if distinct_commodities.len() == 1 {
let sum_splits = splits
.iter()
.map(|s| Rational64::new(s.value_num, s.value_denom))
.reduce(|a, b| a + b)
.ok_or_else(|| FinanceError::Internal(t!("Erroneous split").to_string()))?;
if sum_splits != 0.into() {
return Err(FinanceError::Transaction(TransactionError::Build(
t!("Unbalanced Transaction: sum of splits is non-zero").to_string(),
)));
} else {
// Multi-currency transaction. Just pick the first commodity as
// the base for computations.
let base_commodity = distinct_commodities[0];
// Group splits by commodity to "base vs. price"
let mut splits_by_commodity: HashMap<Uuid, Vec<_>> = HashMap::new();
for s in &splits {
splits_by_commodity
.entry(s.commodity_id)
.or_default()
.push(s);
let mut transaction_sum = Rational64::from_integer(0);
// Sum the "base"
if let Some(base_splits) = splits_by_commodity.get(&base_commodity) {
for s in base_splits {
transaction_sum += Rational64::new(s.value_num, s.value_denom);
return Err(FinanceError::Internal(format!(
"{}",
t!("Internal error: multi-currency transaction is inconsistent")
// Sum the rest with currency conversion
for (commodity_id, split_group) in &splits_by_commodity {
if *commodity_id == base_commodity {
// Already handled
continue;
let split_ids: Vec<Uuid> = split_group.iter().map(|s| s.id).collect();
let prices =
query_file!("sql/transaction_select_prices_by_splits.sql", &split_ids)
if prices.is_empty() {
"{} {}",
t!("No price records found for commodity: "),
commodity_id
// Build a map: split_id -> Vec<price_record>
let mut price_map: HashMap<Uuid, Vec<_>> = HashMap::new();
for p in prices {
// Add both commodity...
if let Some(cid) = p.commodity_split_id {
price_map.entry(cid).or_default().push(p);
} else if let Some(cid) = p.currency_split_id {
// ... and currency
for s in split_group {
let split_val = Rational64::new(s.value_num, s.value_denom);
let split_price = price_map.get(&s.id).ok_or_else(|| {
FinanceError::Internal(format!(
t!("Price not found for split"),
s.id
))
})?;
let price = split_price.first().ok_or_else(|| {
let conv_rate = Rational64::new(price.value_num, price.value_denom);
let sum_converted = if price.commodity_split_id == Some(s.id) {
// "Base" currency matched the currency
split_val * conv_rate
// "Base" currency is commodity, invert
split_val * conv_rate.recip()
};
transaction_sum += sum_converted;
if transaction_sum != 0.into() {
t!("Unbalanced Transaction after conversion: sum != 0").to_string(),
sqltx.commit().await?;
Ok(self.tx)
Err(FinanceError::Internal(
t!("Attempt to commit the empty ticket").to_string(),
/// Rolls back the transaction, reverting all changes.
/// A reference to the original `Transaction` if the rollback is successful.
/// Returns a `FinanceError` if the ticket is empty or the rollback operation fails.
pub async fn rollback(&mut self) -> Result<&'t Transaction, FinanceError> {
if let Some(sqltx) = self.sqltx.take() {
sqltx.rollback().await?;
t!("Attempt to rollback the empty ticket").to_string(),
pub async fn add_splits(&mut self, splits: &[&Split]) -> Result<&'t Transaction, FinanceError> {
if let Some(sqltx) = &mut self.sqltx {
for s in splits {
if s.tx_id != self.tx.id {
return Err(FinanceError::Transaction(TransactionError::WrongSplit(
t!("Attempt to apply split from another transaction").to_string(),
query_file!(
"sql/split_insert.sql",
&s.id,
&s.tx_id,
&s.account_id,
&s.commodity_id,
s.reconcile_state,
s.reconcile_date,
&s.value_num,
&s.value_denom,
s.lot_id
)
.execute(&mut **sqltx)
t!("Adding splits failed").to_string(),
pub async fn add_conversions(
&mut self,
prices: &[&Price],
) -> Result<&'t Transaction, FinanceError> {
p.commit(&mut **sqltx).await?;
t!("Adding conversions failed").to_string(),
pub async fn add_tags(&mut self, tags: &[&Tag]) -> Result<&'t Transaction, FinanceError> {
for t in tags {
t.commit(&mut **sqltx).await?;
query_file!("sql/transaction_tag_set.sql", self.tx.id, t.id,)
Err(FinanceError::Internal(t!("Adding tags failed").to_string()))
impl<'t> Transaction {
/// Inserts the transaction into the database and starts data input.
/// This method begins a new database transaction, inserts the transaction
/// details into the DB, and returns a `TransactionTicket` to manage the
/// rest of data (`Split`s, `Tag`s, and so on). The DB transaction must be
/// committed (or rolled back) from via the `TransactionTicket` returned.
/// # Parameters
/// - `conn`: A connection to the database.
/// A `TransactionTicket` for managing the transaction.
/// Returns a `FinanceError` if the database operation fails.
pub async fn enter<'p, E>(
&'t self,
conn: &'p mut E,
) -> Result<TransactionTicket<'t, 'p>, FinanceError>
where
E: Connection<Database = sqlx::Postgres>,
{
let mut tr = conn.begin().await?;
sqlx::query_file!(
"sql/transaction_insert.sql",
&self.id,
&self.post_date,
&self.enter_date
.execute(&mut *tr)
Ok(TransactionTicket {
sqltx: Some(tr),
tx: self,
})
#[cfg(test)]
mod transaction_tests {
use super::*;
use crate::account::{Account, AccountBuilder};
use crate::commodity::{Commodity, CommodityBuilder};
use crate::split::SplitBuilder;
#[cfg(feature = "testlog")]
use env_logger;
use log;
use sqlx::PgPool;
use sqlx::types::chrono::Local;
use tokio::sync::OnceCell;
/// Context for keeping environment intact
static CONTEXT: OnceCell<()> = OnceCell::const_new();
static COMMODITY: OnceCell<Commodity> = OnceCell::const_new();
static WALLET: OnceCell<Account> = OnceCell::const_new();
static SHOP: OnceCell<Account> = OnceCell::const_new();
static FOREIGN_COMMODITY: OnceCell<Commodity> = OnceCell::const_new();
static ONLINE_SHOP: OnceCell<Account> = OnceCell::const_new();
static FOREIGN_COMMODITY_2: OnceCell<Commodity> = OnceCell::const_new();
static ONLINE_SHOP_2: OnceCell<Account> = OnceCell::const_new();
async fn setup(pool: &PgPool) {
let mut conn = pool.acquire().await.unwrap();
CONTEXT
.get_or_init(|| async {
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Trace)
.try_init();
.await;
COMMODITY
CommodityBuilder::new()
.fraction(1000)
.id(Uuid::new_v4())
.build()
.unwrap()
COMMODITY.get().unwrap().commit(&mut *conn).await.unwrap();
WALLET
.get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
WALLET.get().unwrap().commit(&mut *conn).await.unwrap();
SHOP.get_or_init(|| async { AccountBuilder::new().id(Uuid::new_v4()).build().unwrap() })
SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
FOREIGN_COMMODITY
.fraction(100)
.get()
.commit(&mut *conn)
.await
.unwrap();
ONLINE_SHOP
ONLINE_SHOP.get().unwrap().commit(&mut *conn).await.unwrap();
FOREIGN_COMMODITY_2
ONLINE_SHOP_2
#[sqlx::test(migrations = "../migrations")]
async fn test_transaction_store(pool: PgPool) {
setup(&pool).await;
let transaction = Transaction {
id: Uuid::new_v4(),
post_date: Local::now().into(),
enter_date: Local::now().into(),
&transaction.id,
&transaction.post_date,
&transaction.enter_date
.execute(&mut *conn)
let result = sqlx::query!("SELECT id, post_date FROM transactions")
.fetch_one(&mut *conn)
assert_eq!(transaction.id, result.id);
async fn test_transaction_builer(pool: PgPool) -> anyhow::Result<()> {
let build = Transaction::builder().id(Uuid::new_v4()).build();
assert!(build.is_err());
let build = Transaction::builder()
.post_date(Local::now().into())
.enter_date(Local::now().into())
.build();
assert!(build.is_ok());
Ok(())
async fn test_create_transaction(pool: PgPool) -> anyhow::Result<()> {
let tx = Transaction::builder()
.build()?;
let mut conn = pool.acquire().await?;
let mut tr = tx.enter(&mut *conn).await?;
tr.rollback().await?;
assert!(
sqlx::query!("SELECT id, post_date FROM transactions")
.is_err()
);
assert!(tr.commit().await.is_err()); // Erroneous split
async fn test_transaction_balance(pool: PgPool) -> anyhow::Result<()> {
let split_spend = SplitBuilder::new()
.account_id(WALLET.get().unwrap().id)
.commodity_id(COMMODITY.get().unwrap().id)
.value_num(-100)
.value_denom(1)
.tx_id(tx.id)
let split_purchase = SplitBuilder::new()
.account_id(SHOP.get().unwrap().id)
.value_num(100)
tr.add_splits(&[&split_spend, &split_purchase]).await?;
assert!(tr.commit().await.is_ok());
.value_num(99)
assert!(tr.commit().await.is_err());
async fn test_transaction_multicurrency(pool: PgPool) -> anyhow::Result<()> {
.value_num(-1000)
.account_id(ONLINE_SHOP.get().unwrap().id)
.commodity_id(FOREIGN_COMMODITY.get().unwrap().id)
.value_num(7)
let conversion = Price {
commodity_id: FOREIGN_COMMODITY.get().unwrap().id,
currency_id: COMMODITY.get().unwrap().id,
commodity_split: Some(split_purchase.id),
currency_split: Some(split_spend.id),
date: Local::now().into(),
value_num: 1000,
value_denom: 7,
tr.add_conversions(&[&conversion]).await?;
async fn test_transaction_multicurrency_fail(pool: PgPool) -> anyhow::Result<()> {
let split_spend_2 = SplitBuilder::new()
let split_purchase_2 = SplitBuilder::new()
.account_id(ONLINE_SHOP_2.get().unwrap().id)
.commodity_id(FOREIGN_COMMODITY_2.get().unwrap().id)
.value_num(10)
tr.add_splits(&[
&split_spend,
&split_purchase,
&split_spend_2,
&split_purchase_2,
])
assert!(tr.commit().await.is_err()); // No second conversion
async fn test_transaction_multicurrency_2(pool: PgPool) -> anyhow::Result<()> {
let conversion_2 = Price {
commodity_id: FOREIGN_COMMODITY_2.get().unwrap().id,
commodity_split: Some(split_purchase_2.id),
currency_split: Some(split_spend_2.id),
value_denom: 10,
tr.add_conversions(&[&conversion, &conversion_2]).await?;
async fn test_transaction_unbalanced(pool: PgPool) -> anyhow::Result<()> {
.value_num(90)