Lines
93.88 %
Functions
100 %
Branches
//! Pure, DB-free logical→physical transaction lowering.
//!
//! This module is the single source of truth for the from→to FX math used by
//! every client surface (CLI, TUI, web) and the upcoming B2b RPC native. No
//! database calls, no async.
use chrono::{DateTime, Utc};
use num_rational::Rational64;
use sqlx::types::Uuid;
use crate::command::CmdError;
/// One physical ledger entry.
#[derive(Debug, Clone)]
pub struct PhysicalSplit {
pub account_id: Uuid,
pub commodity_id: Uuid,
pub value: Rational64,
pub id: Uuid,
}
/// One price-table row linking two physical splits.
pub struct PriceRow {
pub currency_id: Uuid,
pub commodity_split: Uuid,
pub currency_split: Uuid,
pub value_num: i64,
pub value_denom: i64,
pub date: Option<DateTime<Utc>>,
/// A from→to exchange; `to_amount` is required iff `from_commodity != to_commodity`.
pub struct LogicalSplit {
pub from: Uuid,
pub to: Uuid,
pub from_commodity: Uuid,
pub to_commodity: Uuid,
pub to_amount: Option<Rational64>,
fn gcd_i128(mut a: i128, mut b: i128) -> i128 {
a = a.abs();
b = b.abs();
while b != 0 {
let t = a % b;
a = b;
b = t;
a
/// The stored FX rate is the magnitude ratio `value / to_amount` (price ×
/// to-side amount = from-side magnitude — the balance-by-construction identity).
/// Widen to i128 before cross-multiplying, reduce, normalise sign so the
/// denominator is positive, then narrow back to i64. Overflow returns an error.
fn reduced_fx_ratio(value: Rational64, to_amount: Rational64) -> Result<(i64, i64), CmdError> {
if *value.numer() == 0 {
return Err(CmdError::Args(
"transaction value must be non-zero for a cross-currency price".to_string(),
));
if *to_amount.numer() == 0 {
"to_amount (exchange rate) must be non-zero".to_string(),
let num = i128::from(*value.numer()) * i128::from(*to_amount.denom());
let den = i128::from(*value.denom()) * i128::from(*to_amount.numer());
let (num, den) = if den < 0 { (-num, -den) } else { (num, den) };
let g = gcd_i128(num, den);
let (num, den) = (num / g, den / g);
let fit = |v: i128, label: &str| {
i64::try_from(v).map_err(|_| {
CmdError::Args(format!("price {label} overflows i64 after reduction: {v}"))
})
};
Ok((fit(num, "value-num")?, fit(den, "value-denom")?))
/// Lower one logical split to two physical splits and an optional price row.
pub fn lower_logical_split(
ls: &LogicalSplit,
) -> Result<(PhysicalSplit, PhysicalSplit, Option<PriceRow>), CmdError> {
let zero = Rational64::from_integer(0);
if ls.value <= zero {
"transaction value must be positive".to_string(),
let actual_to_amount = match (ls.from_commodity == ls.to_commodity, ls.to_amount) {
(true, Some(_)) => {
"to_amount is only valid for cross-currency splits".to_string(),
(true, None) => ls.value,
(false, Some(amount)) if amount <= zero => {
return Err(CmdError::Args("to_amount must be positive".to_string()));
(false, Some(amount)) => amount,
(false, None) => {
"to_amount (required when currencies differ) is required".to_string(),
let from_id = Uuid::new_v4();
let to_id = Uuid::new_v4();
let from_phys = PhysicalSplit {
account_id: ls.from,
commodity_id: ls.from_commodity,
value: -ls.value,
id: from_id,
let to_phys = PhysicalSplit {
account_id: ls.to,
commodity_id: ls.to_commodity,
value: actual_to_amount,
id: to_id,
let price = if ls.from_commodity != ls.to_commodity {
let (value_num, value_denom) = reduced_fx_ratio(ls.value, actual_to_amount)?;
Some(PriceRow {
currency_id: ls.from_commodity,
commodity_split: to_id,
currency_split: from_id,
value_num,
value_denom,
date: None,
} else {
None
Ok((from_phys, to_phys, price))
/// Lower a non-empty slice of logical splits into physical splits and price rows.
pub fn lower_logical_transaction(
logical_splits: &[LogicalSplit],
) -> Result<(Vec<PhysicalSplit>, Vec<PriceRow>), CmdError> {
if logical_splits.is_empty() {
"a transaction needs at least one logical split".to_string(),
let mut splits = Vec::with_capacity(logical_splits.len() * 2);
let mut prices = Vec::new();
for ls in logical_splits {
let (from_phys, to_phys, price) = lower_logical_split(ls)?;
splits.push(from_phys);
splits.push(to_phys);
prices.extend(price);
Ok((splits, prices))
#[cfg(test)]
mod tests;