1use chrono::{DateTime, Utc};
8use num_rational::Rational64;
9use sqlx::types::Uuid;
10
11use crate::command::CmdError;
12
13#[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#[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#[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
56fn 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
84pub 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
142pub 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;