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

            
7
use chrono::{DateTime, Utc};
8
use num_rational::Rational64;
9
use sqlx::types::Uuid;
10

            
11
use crate::command::CmdError;
12

            
13
/// One physical ledger entry.
14
#[derive(Debug, Clone)]
15
pub 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)]
24
pub 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)]
36
pub 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

            
45
304
fn gcd_i128(mut a: i128, mut b: i128) -> i128 {
46
304
    a = a.abs();
47
304
    b = b.abs();
48
970
    while b != 0 {
49
666
        let t = a % b;
50
666
        a = b;
51
666
        b = t;
52
666
    }
53
304
    a
54
304
}
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.
60
304
fn reduced_fx_ratio(value: Rational64, to_amount: Rational64) -> Result<(i64, i64), CmdError> {
61
304
    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
304
    }
66
304
    if *to_amount.numer() == 0 {
67
        return Err(CmdError::Args(
68
            "to_amount (exchange rate) must be non-zero".to_string(),
69
        ));
70
304
    }
71
304
    let num = i128::from(*value.numer()) * i128::from(*to_amount.denom());
72
304
    let den = i128::from(*value.denom()) * i128::from(*to_amount.numer());
73
304
    let (num, den) = if den < 0 { (-num, -den) } else { (num, den) };
74
304
    let g = gcd_i128(num, den);
75
304
    let (num, den) = (num / g, den / g);
76
577
    let fit = |v: i128, label: &str| {
77
577
        i64::try_from(v).map_err(|_| {
78
31
            CmdError::Args(format!("price {label} overflows i64 after reduction: {v}"))
79
31
        })
80
577
    };
81
304
    Ok((fit(num, "value-num")?, fit(den, "value-denom")?))
82
304
}
83

            
84
/// Lower one logical split to two physical splits and an optional price row.
85
1002
pub fn lower_logical_split(
86
1002
    ls: &LogicalSplit,
87
1002
) -> Result<(PhysicalSplit, PhysicalSplit, Option<PriceRow>), CmdError> {
88
1002
    let zero = Rational64::from_integer(0);
89
1002
    if ls.value <= zero {
90
32
        return Err(CmdError::Args(
91
32
            "transaction value must be positive".to_string(),
92
32
        ));
93
970
    }
94
970
    let actual_to_amount = match (ls.from_commodity == ls.to_commodity, ls.to_amount) {
95
        (true, Some(_)) => {
96
1
            return Err(CmdError::Args(
97
1
                "to_amount is only valid for cross-currency splits".to_string(),
98
1
            ));
99
        }
100
542
        (true, None) => ls.value,
101
336
        (false, Some(amount)) if amount <= zero => {
102
32
            return Err(CmdError::Args("to_amount must be positive".to_string()));
103
        }
104
304
        (false, Some(amount)) => amount,
105
        (false, None) => {
106
91
            return Err(CmdError::Args(
107
91
                "to_amount (required when currencies differ) is required".to_string(),
108
91
            ));
109
        }
110
    };
111
846
    let from_id = Uuid::new_v4();
112
846
    let to_id = Uuid::new_v4();
113
846
    let from_phys = PhysicalSplit {
114
846
        account_id: ls.from,
115
846
        commodity_id: ls.from_commodity,
116
846
        value: -ls.value,
117
846
        id: from_id,
118
846
    };
119
846
    let to_phys = PhysicalSplit {
120
846
        account_id: ls.to,
121
846
        commodity_id: ls.to_commodity,
122
846
        value: actual_to_amount,
123
846
        id: to_id,
124
846
    };
125
846
    let price = if ls.from_commodity != ls.to_commodity {
126
304
        let (value_num, value_denom) = reduced_fx_ratio(ls.value, actual_to_amount)?;
127
273
        Some(PriceRow {
128
273
            commodity_id: ls.to_commodity,
129
273
            currency_id: ls.from_commodity,
130
273
            commodity_split: to_id,
131
273
            currency_split: from_id,
132
273
            value_num,
133
273
            value_denom,
134
273
            date: None,
135
273
        })
136
    } else {
137
542
        None
138
    };
139
815
    Ok((from_phys, to_phys, price))
140
1002
}
141

            
142
/// Lower a non-empty slice of logical splits into physical splits and price rows.
143
993
pub fn lower_logical_transaction(
144
993
    logical_splits: &[LogicalSplit],
145
993
) -> Result<(Vec<PhysicalSplit>, Vec<PriceRow>), CmdError> {
146
993
    if logical_splits.is_empty() {
147
31
        return Err(CmdError::Args(
148
31
            "a transaction needs at least one logical split".to_string(),
149
31
        ));
150
962
    }
151
962
    let mut splits = Vec::with_capacity(logical_splits.len() * 2);
152
962
    let mut prices = Vec::new();
153
992
    for ls in logical_splits {
154
992
        let (from_phys, to_phys, price) = lower_logical_split(ls)?;
155
812
        splits.push(from_phys);
156
812
        splits.push(to_phys);
157
812
        prices.extend(price);
158
    }
159
782
    Ok((splits, prices))
160
993
}
161

            
162
#[cfg(test)]
163
mod tests;