1
//! Reverse-lowering of a `get-transaction-detail` plist reply into the
2
//! from→to [`EditableRow`]s that the transaction-edit form uses.
3
//!
4
//! The forward lowering (`server::logical::lower_logical_split`) converts each
5
//! logical from→to split into:
6
//!   - a FROM physical split: `account=from`, `commodity=from_commodity`, `value = -value`
7
//!   - a TO physical split: `account=to`, `commodity=to_commodity`, `value = +to_amount`
8
//!   - for cross-commodity: a price row with `commodity_split=to_id`,
9
//!     `currency_split=from_id`.
10
//!
11
//! This module inverts that mapping.
12

            
13
use std::collections::{HashMap, HashSet};
14

            
15
use nomiscript::{Value, list_to_vec};
16
use num_rational::Rational64;
17

            
18
use crate::eval::{plist_field, plist_field_raw};
19
use crate::forms::validators::{amount_token, parse_amount};
20
use crate::render::{WireValue, parse_wire, reparse_list};
21

            
22
/// A transaction that can be pre-populated into the edit form.
23
#[derive(Debug, Clone, PartialEq)]
24
pub struct EditableTransaction {
25
    pub id: String,
26
    /// Empty string when the transaction has no note.
27
    pub note: String,
28
    /// Post-date in the format the server returned (RFC3339).
29
    pub date: String,
30
    pub rows: Vec<EditableRow>,
31
}
32

            
33
/// One from→to row for the edit form.
34
#[derive(Debug, Clone, PartialEq)]
35
pub struct EditableRow {
36
    pub from_account: String,
37
    pub to_account: String,
38
    pub from_commodity: String,
39
    pub to_commodity: String,
40
    /// Magnitude (positive) of the FROM side, as a string `parse_amount` accepts.
41
    pub value: String,
42
    /// Present only for cross-commodity rows; the TO side amount.
43
    pub to_amount: Option<String>,
44
}
45

            
46
struct ParsedSplit {
47
    id: String,
48
    account_id: String,
49
    commodity_id: String,
50
    value_str: String,
51
}
52

            
53
struct ParsedPrice {
54
    /// The TO-side physical split id (what you GET).
55
    commodity_split: String,
56
    /// The FROM-side physical split id (what you PAY with).
57
    currency_split: String,
58
}
59

            
60
8
fn parse_splits(splits_val: &Value) -> Result<Vec<ParsedSplit>, String> {
61
8
    let elements =
62
8
        list_to_vec(splits_val).ok_or_else(|| "splits field is not a list".to_string())?;
63
8
    elements
64
8
        .iter()
65
21
        .map(|e| {
66
            Ok(ParsedSplit {
67
21
                id: plist_field(e, ":id").ok_or_else(|| "split missing :id".to_string())?,
68
21
                account_id: plist_field(e, ":account-id")
69
21
                    .ok_or_else(|| "split missing :account-id".to_string())?,
70
21
                commodity_id: plist_field(e, ":commodity-id")
71
21
                    .ok_or_else(|| "split missing :commodity-id".to_string())?,
72
21
                value_str: plist_field(e, ":value")
73
21
                    .ok_or_else(|| "split missing :value".to_string())?,
74
            })
75
21
        })
76
8
        .collect()
77
8
}
78

            
79
8
fn parse_prices(prices_val: &Value) -> Result<Vec<ParsedPrice>, String> {
80
8
    let elements =
81
8
        list_to_vec(prices_val).ok_or_else(|| "prices field is not a list".to_string())?;
82
8
    elements
83
8
        .iter()
84
8
        .map(|e| {
85
            Ok(ParsedPrice {
86
5
                commodity_split: plist_field(e, ":commodity-split")
87
5
                    .ok_or_else(|| "price missing :commodity-split".to_string())?,
88
5
                currency_split: plist_field(e, ":currency-split")
89
5
                    .ok_or_else(|| "price missing :currency-split".to_string())?,
90
            })
91
5
        })
92
8
        .collect()
93
8
}
94

            
95
/// Parse a split value into its sign (`-1`/`0`/`+1`) and panic-free magnitude
96
/// string. The magnitude is derived with `checked_abs` so an `i64::MIN`
97
/// numerator surfaces as `Err` instead of overflowing the unary negation.
98
22
fn split_value(value_str: &str) -> Result<(i8, String), String> {
99
22
    let r = parse_amount(value_str)?;
100
22
    let numer = *r.numer();
101
22
    let mag_numer = numer
102
22
        .checked_abs()
103
22
        .ok_or_else(|| format!("value '{value_str}' magnitude overflows i64"))?;
104
22
    let sign = numer.signum() as i8;
105
    // `parse_amount` normalises through `Rational64::new`, so the denominator
106
    // is always strictly positive and coprime with the (now non-negative)
107
    // magnitude numerator — rebuilding the ratio reduces nothing further.
108
22
    let mag = amount_token(&Rational64::new(mag_numer, *r.denom()));
109
22
    Ok((sign, mag))
110
22
}
111

            
112
/// Record `id` as consumed; reject a split claimed by two prices.
113
5
fn consume(consumed: &mut HashSet<String>, id: String) -> Result<(), String> {
114
5
    if consumed.insert(id) {
115
4
        Ok(())
116
    } else {
117
1
        Err("split referenced by multiple prices — ambiguous".to_string())
118
    }
119
5
}
120

            
121
8
fn invert_cross_commodity(
122
8
    prices: Vec<ParsedPrice>,
123
8
    split_map: &HashMap<String, ParsedSplit>,
124
8
    consumed: &mut HashSet<String>,
125
8
) -> Result<Vec<EditableRow>, String> {
126
8
    let mut rows = Vec::with_capacity(prices.len());
127
8
    for price in prices {
128
5
        let from_split = split_map.get(&price.currency_split).ok_or_else(|| {
129
1
            format!(
130
                "price currency-split '{}' not found in splits",
131
                price.currency_split
132
            )
133
1
        })?;
134
4
        let to_split = split_map.get(&price.commodity_split).ok_or_else(|| {
135
            format!(
136
                "price commodity-split '{}' not found in splits",
137
                price.commodity_split
138
            )
139
        })?;
140

            
141
4
        let (from_sign, value) = split_value(&from_split.value_str)?;
142
4
        if from_sign >= 0 {
143
1
            return Err(format!(
144
1
                "cross-currency FROM split '{}' must have a negative value",
145
1
                from_split.id
146
1
            ));
147
3
        }
148
3
        let (to_sign, to_amount) = split_value(&to_split.value_str)?;
149
3
        if to_sign <= 0 {
150
            return Err(format!(
151
                "cross-currency TO split '{}' must have a positive value",
152
                to_split.id
153
            ));
154
3
        }
155

            
156
3
        consume(consumed, price.currency_split)?;
157
2
        consume(consumed, price.commodity_split)?;
158

            
159
2
        rows.push(EditableRow {
160
2
            from_account: from_split.account_id.clone(),
161
2
            to_account: to_split.account_id.clone(),
162
2
            from_commodity: from_split.commodity_id.clone(),
163
2
            to_commodity: to_split.commodity_id.clone(),
164
2
            value,
165
2
            to_amount: Some(to_amount),
166
2
        });
167
    }
168
5
    Ok(rows)
169
8
}
170

            
171
type MagnitudeBucket<'a> = (Vec<&'a ParsedSplit>, Vec<&'a ParsedSplit>);
172

            
173
/// Pair the non-price splits into from→to rows by exact magnitude.
174
///
175
/// The forward lowering stores no link between a row's two physical splits, so
176
/// pairing is unambiguous only when each (commodity, magnitude) holds exactly
177
/// one negative and one positive split. Two same-magnitude splits on either
178
/// side cannot be matched without guessing accounts, so the transaction is
179
/// declared non-editable.
180
5
fn invert_same_commodity(
181
5
    split_map: &HashMap<String, ParsedSplit>,
182
5
    consumed: &HashSet<String>,
183
5
) -> Result<Vec<EditableRow>, String> {
184
5
    let mut buckets: HashMap<(String, String), MagnitudeBucket> = HashMap::new();
185

            
186
15
    for split in split_map.values() {
187
15
        if consumed.contains(&split.id) {
188
2
            continue;
189
13
        }
190
13
        let (sign, mag) =
191
13
            split_value(&split.value_str).map_err(|e| format!("split '{}': {e}", split.id))?;
192
13
        if sign == 0 {
193
            return Err(format!("split '{}' has zero value", split.id));
194
13
        }
195
13
        let bucket = buckets
196
13
            .entry((split.commodity_id.clone(), mag))
197
13
            .or_default();
198
13
        if sign < 0 {
199
7
            bucket.0.push(split);
200
7
        } else {
201
6
            bucket.1.push(split);
202
6
        }
203
    }
204

            
205
5
    let mut rows = Vec::new();
206
5
    for ((commodity_id, mag), (negatives, positives)) in buckets {
207
5
        if negatives.len() > 1 || positives.len() > 1 {
208
1
            return Err(
209
1
                "ambiguous: multiple same-commodity splits of equal magnitude — \
210
1
                 this transaction can't be edited in the form"
211
1
                    .to_string(),
212
1
            );
213
4
        }
214
4
        match (negatives.first(), positives.first()) {
215
3
            (Some(from_split), Some(to_split)) => rows.push(EditableRow {
216
3
                from_account: from_split.account_id.clone(),
217
3
                to_account: to_split.account_id.clone(),
218
3
                from_commodity: commodity_id.clone(),
219
3
                to_commodity: commodity_id,
220
3
                value: mag,
221
3
                to_amount: None,
222
3
            }),
223
            _ => {
224
1
                return Err(format!(
225
1
                    "unpaired same-commodity split of magnitude {mag} in commodity {commodity_id}"
226
1
                ));
227
            }
228
        }
229
    }
230
3
    Ok(rows)
231
5
}
232

            
233
/// Parse the wire reply of `(get-transaction-detail ...)` into editable rows.
234
///
235
/// Returns `Err` when:
236
/// - the wire envelope or plist structure is malformed,
237
/// - a price references a split id not present in `:splits`,
238
/// - remaining (non-price) splits cannot be paired into balanced from→to rows.
239
24
pub fn parse_editable_transaction(wire: &str) -> Result<EditableTransaction, String> {
240
24
    let plist_str = match parse_wire(wire) {
241
9
        Ok(WireValue::Value(Value::String(s))) => s,
242
        Ok(_) => return Err("get-transaction-detail reply is not a string value".to_string()),
243
15
        Err(e) => return Err(format!("wire parse error: {e}")),
244
    };
245

            
246
8
    let plist =
247
9
        reparse_list(&plist_str).map_err(|e| format!("transaction plist re-parse error: {e}"))?;
248

            
249
8
    let id = plist_field(&plist, ":id").ok_or_else(|| "transaction missing :id".to_string())?;
250
8
    let date = plist_field(&plist, ":post-date")
251
8
        .ok_or_else(|| "transaction missing :post-date".to_string())?;
252
8
    let note = plist_field(&plist, ":note").unwrap_or_default();
253

            
254
8
    let splits_val = plist_field_raw(&plist, ":splits")
255
8
        .ok_or_else(|| "transaction missing :splits".to_string())?;
256
8
    let prices_val = plist_field_raw(&plist, ":prices")
257
8
        .ok_or_else(|| "transaction missing :prices".to_string())?;
258

            
259
8
    let splits = parse_splits(&splits_val)?;
260
8
    let prices = parse_prices(&prices_val)?;
261

            
262
8
    let split_map: HashMap<String, ParsedSplit> =
263
21
        splits.into_iter().map(|s| (s.id.clone(), s)).collect();
264

            
265
8
    let mut consumed = HashSet::new();
266
8
    let mut rows = invert_cross_commodity(prices, &split_map, &mut consumed)?;
267
5
    rows.extend(invert_same_commodity(&split_map, &consumed)?);
268
3
    rows.sort_by(|a, b| {
269
1
        (&a.from_account, &a.to_account, &a.value).cmp(&(&b.from_account, &b.to_account, &b.value))
270
1
    });
271

            
272
3
    if rows.is_empty() && !split_map.is_empty() {
273
        return Err("no pairable rows found in transaction".to_string());
274
3
    }
275

            
276
3
    Ok(EditableTransaction {
277
3
        id,
278
3
        note,
279
3
        date,
280
3
        rows,
281
3
    })
282
24
}
283

            
284
#[cfg(test)]
285
mod tests;