Skip to main content

server/
logical.rs

1//! Pure, DB-free logical→physical transaction lowering.
2//!
3//! This module is the single source of truth for the from→to FX math used by
4//! every client surface (CLI, TUI, web) and the upcoming B2b RPC native.  No
5//! database calls, no async.
6
7use chrono::{DateTime, Utc};
8use num_rational::Rational64;
9use sqlx::types::Uuid;
10
11use crate::command::CmdError;
12
13/// One physical ledger entry.
14#[derive(Debug, Clone)]
15pub struct PhysicalSplit {
16    pub account_id: Uuid,
17    pub commodity_id: Uuid,
18    pub value: Rational64,
19    pub id: Uuid,
20}
21
22/// One price-table row linking two physical splits.
23#[derive(Debug, Clone)]
24pub struct PriceRow {
25    pub commodity_id: Uuid,
26    pub currency_id: Uuid,
27    pub commodity_split: Uuid,
28    pub currency_split: Uuid,
29    pub value_num: i64,
30    pub value_denom: i64,
31    pub date: Option<DateTime<Utc>>,
32}
33
34/// A from→to exchange; `to_amount` is required iff `from_commodity != to_commodity`.
35#[derive(Debug, Clone)]
36pub struct LogicalSplit {
37    pub from: Uuid,
38    pub to: Uuid,
39    pub from_commodity: Uuid,
40    pub to_commodity: Uuid,
41    pub value: Rational64,
42    pub to_amount: Option<Rational64>,
43}
44
45fn gcd_i128(mut a: i128, mut b: i128) -> i128 {
46    a = a.abs();
47    b = b.abs();
48    while b != 0 {
49        let t = a % b;
50        a = b;
51        b = t;
52    }
53    a
54}
55
56/// The stored FX rate is the magnitude ratio `value / to_amount` (price ×
57/// to-side amount = from-side magnitude — the balance-by-construction identity).
58/// Widen to i128 before cross-multiplying, reduce, normalise sign so the
59/// denominator is positive, then narrow back to i64.  Overflow returns an error.
60fn reduced_fx_ratio(value: Rational64, to_amount: Rational64) -> Result<(i64, i64), CmdError> {
61    if *value.numer() == 0 {
62        return Err(CmdError::Args(
63            "transaction value must be non-zero for a cross-currency price".to_string(),
64        ));
65    }
66    if *to_amount.numer() == 0 {
67        return Err(CmdError::Args(
68            "to_amount (exchange rate) must be non-zero".to_string(),
69        ));
70    }
71    let num = i128::from(*value.numer()) * i128::from(*to_amount.denom());
72    let den = i128::from(*value.denom()) * i128::from(*to_amount.numer());
73    let (num, den) = if den < 0 { (-num, -den) } else { (num, den) };
74    let g = gcd_i128(num, den);
75    let (num, den) = (num / g, den / g);
76    let fit = |v: i128, label: &str| {
77        i64::try_from(v).map_err(|_| {
78            CmdError::Args(format!("price {label} overflows i64 after reduction: {v}"))
79        })
80    };
81    Ok((fit(num, "value-num")?, fit(den, "value-denom")?))
82}
83
84/// Lower one logical split to two physical splits and an optional price row.
85pub fn lower_logical_split(
86    ls: &LogicalSplit,
87) -> Result<(PhysicalSplit, PhysicalSplit, Option<PriceRow>), CmdError> {
88    let zero = Rational64::from_integer(0);
89    if ls.value <= zero {
90        return Err(CmdError::Args(
91            "transaction value must be positive".to_string(),
92        ));
93    }
94    let actual_to_amount = match (ls.from_commodity == ls.to_commodity, ls.to_amount) {
95        (true, Some(_)) => {
96            return Err(CmdError::Args(
97                "to_amount is only valid for cross-currency splits".to_string(),
98            ));
99        }
100        (true, None) => ls.value,
101        (false, Some(amount)) if amount <= zero => {
102            return Err(CmdError::Args("to_amount must be positive".to_string()));
103        }
104        (false, Some(amount)) => amount,
105        (false, None) => {
106            return Err(CmdError::Args(
107                "to_amount (required when currencies differ) is required".to_string(),
108            ));
109        }
110    };
111    let from_id = Uuid::new_v4();
112    let to_id = Uuid::new_v4();
113    let from_phys = PhysicalSplit {
114        account_id: ls.from,
115        commodity_id: ls.from_commodity,
116        value: -ls.value,
117        id: from_id,
118    };
119    let to_phys = PhysicalSplit {
120        account_id: ls.to,
121        commodity_id: ls.to_commodity,
122        value: actual_to_amount,
123        id: to_id,
124    };
125    let price = if ls.from_commodity != ls.to_commodity {
126        let (value_num, value_denom) = reduced_fx_ratio(ls.value, actual_to_amount)?;
127        Some(PriceRow {
128            commodity_id: ls.to_commodity,
129            currency_id: ls.from_commodity,
130            commodity_split: to_id,
131            currency_split: from_id,
132            value_num,
133            value_denom,
134            date: None,
135        })
136    } else {
137        None
138    };
139    Ok((from_phys, to_phys, price))
140}
141
142/// Lower a non-empty slice of logical splits into physical splits and price rows.
143pub fn lower_logical_transaction(
144    logical_splits: &[LogicalSplit],
145) -> Result<(Vec<PhysicalSplit>, Vec<PriceRow>), CmdError> {
146    if logical_splits.is_empty() {
147        return Err(CmdError::Args(
148            "a transaction needs at least one logical split".to_string(),
149        ));
150    }
151    let mut splits = Vec::with_capacity(logical_splits.len() * 2);
152    let mut prices = Vec::new();
153    for ls in logical_splits {
154        let (from_phys, to_phys, price) = lower_logical_split(ls)?;
155        splits.push(from_phys);
156        splits.push(to_phys);
157        prices.extend(price);
158    }
159    Ok((splits, prices))
160}
161
162#[cfg(test)]
163mod tests;