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

            
84
/// Lower one logical split to two physical splits and an optional price row.
85
1299
pub fn lower_logical_split(
86
1299
    ls: &LogicalSplit,
87
1299
) -> Result<(PhysicalSplit, PhysicalSplit, Option<PriceRow>), CmdError> {
88
1299
    let zero = Rational64::from_integer(0);
89
1299
    if ls.value <= zero {
90
41
        return Err(CmdError::Args(
91
41
            "transaction value must be positive".to_string(),
92
41
        ));
93
1258
    }
94
1258
    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
704
        (true, None) => ls.value,
101
435
        (false, Some(amount)) if amount <= zero => {
102
41
            return Err(CmdError::Args("to_amount must be positive".to_string()));
103
        }
104
394
        (false, Some(amount)) => amount,
105
        (false, None) => {
106
118
            return Err(CmdError::Args(
107
118
                "to_amount (required when currencies differ) is required".to_string(),
108
118
            ));
109
        }
110
    };
111
1098
    let from_id = Uuid::new_v4();
112
1098
    let to_id = Uuid::new_v4();
113
1098
    let from_phys = PhysicalSplit {
114
1098
        account_id: ls.from,
115
1098
        commodity_id: ls.from_commodity,
116
1098
        value: -ls.value,
117
1098
        id: from_id,
118
1098
    };
119
1098
    let to_phys = PhysicalSplit {
120
1098
        account_id: ls.to,
121
1098
        commodity_id: ls.to_commodity,
122
1098
        value: actual_to_amount,
123
1098
        id: to_id,
124
1098
    };
125
1098
    let price = if ls.from_commodity != ls.to_commodity {
126
394
        let (value_num, value_denom) = reduced_fx_ratio(ls.value, actual_to_amount)?;
127
354
        Some(PriceRow {
128
354
            commodity_id: ls.to_commodity,
129
354
            currency_id: ls.from_commodity,
130
354
            commodity_split: to_id,
131
354
            currency_split: from_id,
132
354
            value_num,
133
354
            value_denom,
134
354
            date: None,
135
354
        })
136
    } else {
137
704
        None
138
    };
139
1058
    Ok((from_phys, to_phys, price))
140
1299
}
141

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

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