1
//! Pure payload builders for transaction create and update nomiscript forms.
2
//!
3
//! The plist payload shape:
4
//! `(:splits ((:from "uuid" :to "uuid" :from-commodity "uuid"
5
//!   :to-commodity "uuid" :value <ratio> :to-amount <ratio>?) ...)
6
//!   :note "..."? :date "..."?)`
7

            
8
use crate::eval::escape_str;
9
use crate::forms::validators::{LogicalSplitInput, amount_token, parse_amount};
10

            
11
26
fn format_ratio(s: &str) -> Result<String, String> {
12
26
    parse_amount(s).map(|r| amount_token(&r))
13
26
}
14

            
15
24
fn format_split(row: &LogicalSplitInput) -> Result<String, String> {
16
24
    let value_str = format_ratio(&row.amount)?;
17
24
    let to_amount_part = if row.from_commodity != row.to_commodity {
18
4
        let ta = row
19
4
            .to_amount
20
4
            .as_deref()
21
4
            .ok_or_else(|| "to_amount required for cross-commodity split".to_string())?;
22
2
        format!(" :to-amount {}", format_ratio(ta)?)
23
    } else {
24
20
        String::new()
25
    };
26
22
    Ok(format!(
27
22
        "(:from \"{from}\" :to \"{to}\" :from-commodity \"{fc}\" :to-commodity \"{tc}\" :value {value}{ta})",
28
22
        from = row.from,
29
22
        to = row.to,
30
22
        fc = row.from_commodity,
31
22
        tc = row.to_commodity,
32
22
        value = value_str,
33
22
        ta = to_amount_part,
34
22
    ))
35
24
}
36

            
37
22
fn build_note_date_parts(note: &str, date: &str) -> (String, String) {
38
22
    let note_part = if note.is_empty() {
39
7
        String::new()
40
    } else {
41
15
        format!(" :note {}", escape_str(note))
42
    };
43
22
    let date_part = if date.is_empty() {
44
7
        String::new()
45
    } else {
46
15
        format!(" :date {}", escape_str(date))
47
    };
48
22
    (note_part, date_part)
49
22
}
50

            
51
24
fn build_splits_plist(splits: &[LogicalSplitInput]) -> Result<String, String> {
52
24
    splits
53
24
        .iter()
54
24
        .map(format_split)
55
24
        .collect::<Result<Vec<_>, _>>()
56
24
        .map(|v| v.join(" "))
57
24
}
58

            
59
/// Build the `(create-transaction-logical "...")` nomiscript form.
60
///
61
/// Returns `Err` if any amount fails to parse or a cross-commodity row is
62
/// missing `to_amount`.
63
6
pub fn build_transaction_logical_payload(
64
6
    splits: &[LogicalSplitInput],
65
6
    note: &str,
66
6
    date: &str,
67
6
) -> Result<String, String> {
68
6
    let splits_part = build_splits_plist(splits)?;
69
5
    let (note_part, date_part) = build_note_date_parts(note, date);
70
5
    let payload = format!("(:splits ({splits_part}){note_part}{date_part})");
71
5
    Ok(format!(
72
5
        "(create-transaction-logical {})",
73
5
        escape_str(&payload)
74
5
    ))
75
6
}
76

            
77
/// Build the `(update-transaction-logical "...")` nomiscript form.
78
///
79
/// Returns `Err` if any amount fails to parse or a cross-commodity row is
80
/// missing `to_amount`.
81
18
pub fn build_transaction_update_payload(
82
18
    id: &str,
83
18
    splits: &[LogicalSplitInput],
84
18
    note: &str,
85
18
    date: &str,
86
18
) -> Result<String, String> {
87
18
    let splits_part = build_splits_plist(splits)?;
88
17
    let (note_part, date_part) = build_note_date_parts(note, date);
89
17
    let payload =
90
17
        format!("(:transaction-id \"{id}\" :splits ({splits_part}){note_part}{date_part})");
91
17
    Ok(format!(
92
17
        "(update-transaction-logical {})",
93
17
        escape_str(&payload)
94
17
    ))
95
18
}
96

            
97
#[cfg(test)]
98
mod tests {
99
    use super::*;
100
    use crate::forms::validators::LogicalSplitInput;
101

            
102
4
    fn row_same(from: &str, to: &str, comm: &str, value: &str) -> LogicalSplitInput {
103
4
        LogicalSplitInput {
104
4
            from: from.to_string(),
105
4
            to: to.to_string(),
106
4
            from_commodity: comm.to_string(),
107
4
            to_commodity: comm.to_string(),
108
4
            amount: value.to_string(),
109
4
            to_amount: None,
110
4
        }
111
4
    }
112

            
113
1
    fn row_cross(
114
1
        from: &str,
115
1
        to: &str,
116
1
        fc: &str,
117
1
        tc: &str,
118
1
        value: &str,
119
1
        to_amount: &str,
120
1
    ) -> LogicalSplitInput {
121
1
        LogicalSplitInput {
122
1
            from: from.to_string(),
123
1
            to: to.to_string(),
124
1
            from_commodity: fc.to_string(),
125
1
            to_commodity: tc.to_string(),
126
1
            amount: value.to_string(),
127
1
            to_amount: Some(to_amount.to_string()),
128
1
        }
129
1
    }
130

            
131
    const FROM: &str = "aaaa0000-0000-0000-0000-000000000001";
132
    const TO: &str = "aaaa0000-0000-0000-0000-000000000002";
133
    const COMM: &str = "cccc0000-0000-0000-0000-000000000001";
134
    const COMM2: &str = "cccc0000-0000-0000-0000-000000000002";
135

            
136
    #[test]
137
1
    fn single_currency_integer_value() {
138
1
        let splits = vec![row_same(FROM, TO, COMM, "50")];
139
1
        let result = build_transaction_logical_payload(&splits, "", "").unwrap();
140
1
        assert!(
141
1
            result.starts_with("(create-transaction-logical "),
142
            "form prefix: {result}"
143
        );
144
1
        assert!(result.contains(":value 50"), "integer ratio: {result}");
145
1
        assert!(
146
1
            !result.contains(":to-amount"),
147
            "no to-amount same commodity: {result}"
148
        );
149
1
    }
150

            
151
    #[test]
152
1
    fn cross_currency_decimal_value() {
153
1
        let splits = vec![row_cross(FROM, TO, COMM, COMM2, "153.81", "15000")];
154
1
        let result = build_transaction_logical_payload(&splits, "", "").unwrap();
155
1
        assert!(
156
1
            result.contains(":value 15381/100"),
157
            "decimal to ratio: {result}"
158
        );
159
1
        assert!(
160
1
            result.contains(":to-amount 15000"),
161
            "to-amount present: {result}"
162
        );
163
1
    }
164

            
165
    #[test]
166
1
    fn note_and_date_included() {
167
1
        let splits = vec![row_same(FROM, TO, COMM, "10")];
168
1
        let result = build_transaction_logical_payload(&splits, "Groceries", "2024-01-15").unwrap();
169
1
        assert!(result.contains(":note"), "note present: {result}");
170
1
        assert!(result.contains(":date"), "date present: {result}");
171
1
        assert!(result.contains("Groceries"), "note text: {result}");
172
1
    }
173

            
174
    #[test]
175
1
    fn fraction_amount_emitted_as_ratio() {
176
1
        let splits = vec![row_same(FROM, TO, COMM, "1/3")];
177
1
        let result = build_transaction_logical_payload(&splits, "", "").unwrap();
178
1
        assert!(
179
1
            result.contains(":value 1/3"),
180
            "fraction preserved: {result}"
181
        );
182
1
    }
183

            
184
    #[test]
185
1
    fn cross_commodity_missing_to_amount_is_error() {
186
1
        let row = LogicalSplitInput {
187
1
            from: FROM.to_string(),
188
1
            to: TO.to_string(),
189
1
            from_commodity: COMM.to_string(),
190
1
            to_commodity: COMM2.to_string(),
191
1
            amount: "10".to_string(),
192
1
            to_amount: None,
193
1
        };
194
1
        assert!(
195
1
            build_transaction_logical_payload(&[row], "", "").is_err(),
196
            "cross-commodity without to_amount must fail"
197
        );
198
1
    }
199

            
200
    #[test]
201
1
    fn empty_note_and_date_omitted() {
202
1
        let splits = vec![row_same(FROM, TO, COMM, "5")];
203
1
        let result = build_transaction_logical_payload(&splits, "", "").unwrap();
204
1
        assert!(!result.contains(":note"), "no note when empty: {result}");
205
1
        assert!(!result.contains(":date"), "no date when empty: {result}");
206
1
    }
207
}