Lines
73.16 %
Functions
26.96 %
Branches
100 %
//web/src/pages/transaction/util.rs - Shared utility functions for transaction operations
use axum::{Json, http::StatusCode};
use chrono::{Local, NaiveDateTime};
use finance::price::Price;
use num_rational::Rational64;
use serde::Deserialize;
use server::command::{CmdResult, FinanceEntity, commodity::GetCommodity};
use sqlx::types::Uuid;
use std::collections::HashMap;
/// Common `SplitData` structure used by both create and edit
#[derive(Deserialize, Debug)]
pub struct SplitData {
pub split_id: Option<String>, // Only used by edit
pub amount: String,
pub amount_converted: String,
pub from_account: String,
pub to_account: String,
pub from_commodity: String,
pub to_commodity: String,
pub from_tags: Option<Vec<TagData>>,
pub to_tags: Option<Vec<TagData>>,
}
pub struct TagData {
pub name: String,
pub value: String,
pub description: Option<String>,
/// Result of processing a single split
pub struct ProcessedSplit {
pub from_split: finance::split::Split,
pub to_split: finance::split::Split,
pub price: Option<Price>,
pub from_split_tags: Option<Vec<TagData>>,
pub to_split_tags: Option<Vec<TagData>>,
/// Get account name by ID
pub async fn get_account_name(
user_id: Uuid,
account_id: Uuid,
) -> Result<String, Box<dyn std::error::Error>> {
match server::command::account::GetAccount::new()
.user_id(user_id)
.account_id(account_id)
.run()
.await?
{
Some(CmdResult::TaggedEntities { entities, .. }) => {
if let Some((FinanceEntity::Account(_account), tags)) = entities.first() {
if let Some(FinanceEntity::Tag(name_tag)) = tags.get("name") {
Ok(name_tag.tag_value.clone())
} else {
Ok("Unnamed Account".to_string())
Ok("Unknown Account".to_string())
_ => Ok("Unknown Account".to_string()),
/// Get commodity symbol (or name as fallback) by ID
pub async fn get_commodity_name(
commodity_id: Uuid,
match GetCommodity::new()
.commodity_id(commodity_id)
if let Some((FinanceEntity::Commodity(_commodity), tags)) = entities.first() {
if let Some(FinanceEntity::Tag(symbol_tag)) = tags.get("symbol") {
Ok(symbol_tag.tag_value.clone())
} else if let Some(FinanceEntity::Tag(name_tag)) = tags.get("name") {
Ok("Unknown Currency".to_string())
_ => Ok("Unknown Currency".to_string()),
/// Parse RFC3339 date string from browser (via `toISOString()`) or return current time.
#[must_use]
pub fn parse_transaction_date(date_str: Option<&str>) -> NaiveDateTime {
date_str
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map_or_else(|| Local::now().naive_utc(), |dt| dt.naive_utc())
/// Parse and validate UUID with custom error message
pub fn parse_uuid(
uuid_str: &str,
field_name: &str,
) -> Result<Uuid, (StatusCode, Json<serde_json::Value>)> {
Uuid::parse_str(uuid_str).map_err(|_| {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("Invalid {}: {}", field_name, uuid_str),
});
(StatusCode::BAD_REQUEST, Json(error_response))
})
/// Validate basic amount parsing and positivity (without precision checking)
pub fn validate_basic_amount(
amount_str: &str,
) -> Result<f64, (StatusCode, Json<serde_json::Value>)> {
let amount_value = amount_str.parse::<f64>().map_err(|_| {
"message": format!("Invalid amount: {}", amount_str),
})?;
if amount_value <= 0.0 {
"message": t!("Split amount must be positive"),
return Err((StatusCode::BAD_REQUEST, Json(error_response)));
Ok(amount_value)
/// Parse a decimal string directly into an exact rational (numerator, denominator).
///
/// `"153.81"` becomes `(15381, 100)` — no floating-point intermediary.
pub fn parse_amount_to_rational(
) -> Result<(i64, i64), (StatusCode, Json<serde_json::Value>)> {
validate_basic_amount(amount_str)?;
let trimmed = amount_str.trim();
let (numer, denom) = if let Some(dot_pos) = trimmed.find('.') {
let decimals = trimmed.len() - dot_pos - 1;
let without_dot: String = trimmed.chars().filter(|c| *c != '.').collect();
let n = without_dot.parse::<i64>().map_err(|_| {
"message": format!("Cannot represent amount as rational: {}", amount_str),
(n, 10_i64.pow(decimals as u32))
let n = trimmed.parse::<i64>().map_err(|_| {
(n, 1)
};
let r = Rational64::new(numer, denom);
Ok((*r.numer(), *r.denom()))
/// Process a single split data into finance entities
pub async fn process_split_data(
tx_id: Uuid,
split_data: SplitData,
) -> Result<ProcessedSplit, (StatusCode, Json<serde_json::Value>)> {
validate_basic_amount(&split_data.amount)?;
let from_account_id = parse_uuid(&split_data.from_account, "from account ID")?;
let to_account_id = parse_uuid(&split_data.to_account, "to account ID")?;
let from_commodity = parse_uuid(&split_data.from_commodity, "from commodity ID")?;
let to_commodity = parse_uuid(&split_data.to_commodity, "to commodity ID")?;
// Only validate amount_converted if currency conversion is needed
let conversion = from_commodity != to_commodity;
if conversion {
validate_basic_amount(&split_data.amount_converted)?;
let (from_num, from_denom) = parse_amount_to_rational(&split_data.amount)?;
let from_split_id = Uuid::new_v4();
let to_split_id = Uuid::new_v4();
let (to_num, to_denom, price) = if conversion {
let (to_num, to_denom) = parse_amount_to_rational(&split_data.amount_converted)?;
let price = build_conversion_price(
from_split_id,
to_split_id,
from_commodity,
to_commodity,
from_num,
from_denom,
to_num,
to_denom,
);
(to_num, to_denom, Some(price))
(from_num, from_denom, None)
// Create split entities
let from_split = finance::split::Split {
id: from_split_id,
tx_id,
account_id: from_account_id,
commodity_id: from_commodity,
value_num: -from_num,
value_denom: from_denom,
reconcile_state: None,
reconcile_date: None,
lot_id: None,
let to_split = finance::split::Split {
id: to_split_id,
account_id: to_account_id,
commodity_id: to_commodity,
value_num: to_num,
value_denom: to_denom,
Ok(ProcessedSplit {
from_split,
to_split,
price,
from_split_tags: split_data.from_tags,
to_split_tags: split_data.to_tags,
/// Create transaction tags from note
pub fn create_transaction_tags(note: Option<&str>) -> HashMap<String, FinanceEntity> {
let mut tags = HashMap::new();
if let Some(note_str) = note
&& !note_str.trim().is_empty()
let tag = finance::tag::Tag {
id: Uuid::new_v4(),
tag_name: "note".to_string(),
tag_value: note_str.to_string(),
description: None,
tags.insert("note".to_string(), FinanceEntity::Tag(tag));
tags
/// Build a Price for a multi-currency split from rational components.
/// `from` is the spent amount (will be negated in the split),
/// `to` is the received amount in a different commodity.
/// Returns a Price whose rate converts `to` back to `from` units.
pub fn build_conversion_price(
from_split_id: Uuid,
to_split_id: Uuid,
from_commodity: Uuid,
to_commodity: Uuid,
from_num: i64,
from_denom: i64,
to_num: i64,
to_denom: i64,
) -> Price {
Price {
date: chrono::Utc::now(),
currency_id: from_commodity,
commodity_split: Some(to_split_id),
currency_split: Some(from_split_id),
value_num: from_num * to_denom,
value_denom: from_denom * to_num,
/// Validate that splits are not empty
pub fn validate_splits_not_empty<T>(
splits: &[T],
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
if splits.is_empty() {
"message": t!("At least one split is required for a transaction"),
Ok(())
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_amount_integer() {
let (num, denom) = parse_amount_to_rational("25584").unwrap();
assert_eq!(Rational64::new(num, denom), Rational64::from_integer(25584));
fn test_parse_amount_fractional() {
let (num, denom) = parse_amount_to_rational("153.81").unwrap();
let r = Rational64::new(num, denom);
assert_eq!(r, Rational64::new(15381, 100));
fn test_conversion_price_jpy_usd() {
let from_id = Uuid::new_v4();
let to_id = Uuid::new_v4();
let from_commodity = Uuid::new_v4();
let to_commodity = Uuid::new_v4();
let (from_num, from_denom) = parse_amount_to_rational("25584").unwrap();
let (to_num, to_denom) = parse_amount_to_rational("153.81").unwrap();
from_id,
to_id,
let from_val = Rational64::new(-from_num, from_denom);
let to_val = Rational64::new(to_num, to_denom);
let conv_rate = Rational64::new(price.value_num, price.value_denom);
// commodity_split is to_split, so to_val * conv_rate must cancel from_val
assert_eq!(from_val + to_val * conv_rate, Rational64::from_integer(0));
fn test_conversion_price_integer_amounts() {
let (from_num, from_denom) = parse_amount_to_rational("1000").unwrap();
let (to_num, to_denom) = parse_amount_to_rational("7").unwrap();
fn test_conversion_price_both_fractional() {
let (from_num, from_denom) = parse_amount_to_rational("99.50").unwrap();
let (to_num, to_denom) = parse_amount_to_rational("85.23").unwrap();