1
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
2
use finance::price::Price;
3
use finance::split::Split;
4
use nomiscript::{Expr, Fraction, Reader};
5
use uuid::Uuid;
6

            
7
#[derive(Debug)]
8
pub(super) struct UpdateTransactionInput {
9
    pub(super) transaction_id: Uuid,
10
    pub(super) post_date: Option<DateTime<Utc>>,
11
    pub(super) enter_date: Option<DateTime<Utc>>,
12
    pub(super) note: Option<String>,
13
    pub(super) splits: Option<Vec<Split>>,
14
    pub(super) prices: Vec<Price>,
15
}
16

            
17
#[derive(Debug)]
18
pub(super) struct CreateTransactionInput {
19
    pub(super) id: Uuid,
20
    pub(super) post_date: DateTime<Utc>,
21
    pub(super) enter_date: DateTime<Utc>,
22
    pub(super) note: Option<String>,
23
    pub(super) splits: Vec<Split>,
24
    pub(super) prices: Vec<Price>,
25
}
26

            
27
31
pub(super) fn parse_update_transaction_payload(
28
31
    src: &str,
29
31
) -> Result<UpdateTransactionInput, String> {
30
31
    let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
31
31
    let first = program
32
31
        .exprs
33
31
        .into_iter()
34
31
        .next()
35
31
        .ok_or_else(|| "payload empty".to_string())?;
36
31
    let plist = expect_plist(first, "payload")?;
37
31
    let tx_raw = take_plist_string(&plist, "transaction-id")?
38
31
        .ok_or_else(|| "payload: missing :transaction-id".to_string())?;
39
30
    let transaction_id = Uuid::parse_str(&tx_raw)
40
30
        .map_err(|err| format!("payload: invalid :transaction-id '{tx_raw}': {err}"))?;
41
30
    let post_date = match take_plist_string(&plist, "post-date")? {
42
1
        Some(raw) => Some(
43
1
            DateTime::parse_from_rfc3339(&raw)
44
1
                .map(|d| d.with_timezone(&Utc))
45
1
                .map_err(|err| format!("payload: invalid :post-date '{raw}': {err}"))?,
46
        ),
47
29
        None => None,
48
    };
49
30
    let enter_date = match take_plist_string(&plist, "enter-date")? {
50
        Some(raw) => Some(
51
            DateTime::parse_from_rfc3339(&raw)
52
                .map(|d| d.with_timezone(&Utc))
53
                .map_err(|err| format!("payload: invalid :enter-date '{raw}': {err}"))?,
54
        ),
55
30
        None => None,
56
    };
57
30
    let note = take_plist_string(&plist, "note")?;
58
30
    let splits = match take_plist_list(&plist, "splits")? {
59
2
        Some(list) => {
60
2
            if list.len() < 2 {
61
                return Err("payload: :splits must have at least two entries".into());
62
2
            }
63
            Some(
64
2
                list.into_iter()
65
2
                    .enumerate()
66
4
                    .map(|(idx, e)| parse_split_plist(e, idx))
67
2
                    .collect::<Result<Vec<_>, _>>()?,
68
            )
69
        }
70
28
        None => None,
71
    };
72
30
    let price_default_date = post_date.unwrap_or_else(Utc::now);
73
30
    let prices = match take_plist_list(&plist, "prices")? {
74
2
        Some(list) => list
75
2
            .into_iter()
76
2
            .enumerate()
77
2
            .map(|(idx, expr)| parse_price_plist(expr, idx, price_default_date))
78
2
            .collect::<Result<Vec<_>, _>>()?,
79
28
        None => Vec::new(),
80
    };
81
30
    Ok(UpdateTransactionInput {
82
30
        transaction_id,
83
30
        post_date,
84
30
        enter_date,
85
30
        note,
86
30
        splits,
87
30
        prices,
88
30
    })
89
31
}
90

            
91
736
pub(super) fn parse_create_transaction_payload(
92
736
    src: &str,
93
736
) -> Result<CreateTransactionInput, String> {
94
736
    let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
95
736
    let first = program
96
736
        .exprs
97
736
        .into_iter()
98
736
        .next()
99
736
        .ok_or_else(|| "payload empty".to_string())?;
100
736
    let plist = expect_plist(first, "payload")?;
101
735
    let post_date_raw = take_plist_string(&plist, "post-date")?
102
735
        .ok_or_else(|| "payload: missing :post-date".to_string())?;
103
734
    let post_date = DateTime::parse_from_rfc3339(&post_date_raw)
104
734
        .map(|d| d.with_timezone(&Utc))
105
734
        .map_err(|err| format!("payload: invalid :post-date '{post_date_raw}': {err}"))?;
106
734
    let enter_date = match take_plist_string(&plist, "enter-date")? {
107
26
        Some(raw) => DateTime::parse_from_rfc3339(&raw)
108
26
            .map(|d| d.with_timezone(&Utc))
109
26
            .map_err(|err| format!("payload: invalid :enter-date '{raw}': {err}"))?,
110
708
        None => Utc::now(),
111
    };
112
734
    let id = match take_plist_string(&plist, "id")? {
113
        Some(raw) => {
114
            Uuid::parse_str(&raw).map_err(|err| format!("payload: invalid :id '{raw}': {err}"))?
115
        }
116
734
        None => Uuid::new_v4(),
117
    };
118
734
    let note = take_plist_string(&plist, "note")?;
119
734
    let splits_list =
120
734
        take_plist_list(&plist, "splits")?.ok_or_else(|| "payload: missing :splits".to_string())?;
121
734
    if splits_list.len() < 2 {
122
1
        return Err("payload: :splits must have at least two entries".into());
123
733
    }
124
733
    let splits = splits_list
125
733
        .into_iter()
126
733
        .enumerate()
127
1492
        .map(|(idx, expr)| parse_split_plist(expr, idx))
128
733
        .collect::<Result<Vec<_>, _>>()?;
129
733
    let prices = match take_plist_list(&plist, "prices")? {
130
54
        Some(list) => list
131
54
            .into_iter()
132
54
            .enumerate()
133
54
            .map(|(idx, expr)| parse_price_plist(expr, idx, post_date))
134
54
            .collect::<Result<Vec<_>, _>>()?,
135
679
        None => Vec::new(),
136
    };
137
733
    Ok(CreateTransactionInput {
138
733
        id,
139
733
        post_date,
140
733
        enter_date,
141
733
        note,
142
733
        splits,
143
733
        prices,
144
733
    })
145
736
}
146

            
147
1496
pub(super) fn parse_split_plist(expr: Expr, idx: usize) -> Result<Split, String> {
148
1496
    let plist = expect_plist(expr, &format!("split[{idx}]"))?;
149
1496
    let id = match take_plist_string(&plist, "id")? {
150
165
        Some(raw) => Uuid::parse_str(&raw)
151
165
            .map_err(|err| format!("split[{idx}]: invalid :id '{raw}': {err}"))?,
152
1331
        None => Uuid::new_v4(),
153
    };
154
1496
    let account_raw = take_plist_string(&plist, "account-id")?
155
1496
        .ok_or_else(|| format!("split[{idx}]: missing :account-id"))?;
156
1496
    let account_id = Uuid::parse_str(&account_raw)
157
1496
        .map_err(|err| format!("split[{idx}]: invalid :account-id '{account_raw}': {err}"))?;
158
1496
    let commodity_raw = take_plist_string(&plist, "commodity-id")?
159
1496
        .ok_or_else(|| format!("split[{idx}]: missing :commodity-id"))?;
160
1496
    let commodity_id = Uuid::parse_str(&commodity_raw)
161
1496
        .map_err(|err| format!("split[{idx}]: invalid :commodity-id '{commodity_raw}': {err}"))?;
162
1496
    let value = take_plist_number(&plist, "value")?
163
1496
        .ok_or_else(|| format!("split[{idx}]: missing :value"))?;
164
1496
    Ok(Split {
165
1496
        id,
166
1496
        tx_id: Uuid::nil(),
167
1496
        account_id,
168
1496
        commodity_id,
169
1496
        reconcile_state: None,
170
1496
        reconcile_date: None,
171
1496
        value_num: *value.numer(),
172
1496
        value_denom: *value.denom(),
173
1496
        lot_id: None,
174
1496
    })
175
1496
}
176

            
177
56
pub(super) fn parse_price_plist(
178
56
    expr: Expr,
179
56
    idx: usize,
180
56
    default_date: DateTime<Utc>,
181
56
) -> Result<Price, String> {
182
56
    let plist = expect_plist(expr, &format!("price[{idx}]"))?;
183
56
    let id = match take_plist_string(&plist, "id")? {
184
        Some(raw) => Uuid::parse_str(&raw)
185
            .map_err(|err| format!("price[{idx}]: invalid :id '{raw}': {err}"))?,
186
56
        None => Uuid::new_v4(),
187
    };
188
56
    let commodity_raw = take_plist_string(&plist, "commodity-id")?
189
56
        .ok_or_else(|| format!("price[{idx}]: missing :commodity-id"))?;
190
56
    let commodity_id = Uuid::parse_str(&commodity_raw)
191
56
        .map_err(|err| format!("price[{idx}]: invalid :commodity-id '{commodity_raw}': {err}"))?;
192
56
    let currency_raw = take_plist_string(&plist, "currency-id")?
193
56
        .ok_or_else(|| format!("price[{idx}]: missing :currency-id"))?;
194
56
    let currency_id = Uuid::parse_str(&currency_raw)
195
56
        .map_err(|err| format!("price[{idx}]: invalid :currency-id '{currency_raw}': {err}"))?;
196
56
    let commodity_split_raw = take_plist_string(&plist, "commodity-split")?
197
56
        .ok_or_else(|| format!("price[{idx}]: missing :commodity-split"))?;
198
56
    let commodity_split = Uuid::parse_str(&commodity_split_raw).map_err(|err| {
199
        format!("price[{idx}]: invalid :commodity-split '{commodity_split_raw}': {err}")
200
    })?;
201
56
    let currency_split_raw = take_plist_string(&plist, "currency-split")?
202
56
        .ok_or_else(|| format!("price[{idx}]: missing :currency-split"))?;
203
56
    let currency_split = Uuid::parse_str(&currency_split_raw).map_err(|err| {
204
        format!("price[{idx}]: invalid :currency-split '{currency_split_raw}': {err}")
205
    })?;
206
56
    let value_num = take_plist_i64(&plist, "value-num")?
207
56
        .ok_or_else(|| format!("price[{idx}]: missing :value-num"))?;
208
56
    let value_denom = take_plist_i64(&plist, "value-denom")?
209
56
        .ok_or_else(|| format!("price[{idx}]: missing :value-denom"))?;
210
56
    if value_denom == 0 {
211
        return Err(format!("price[{idx}]: :value-denom must be non-zero"));
212
56
    }
213
56
    let date = match take_plist_string(&plist, "date")? {
214
1
        Some(raw) => DateTime::parse_from_rfc3339(&raw)
215
1
            .map(|d| d.with_timezone(&Utc))
216
1
            .map_err(|err| format!("price[{idx}]: invalid :date '{raw}': {err}"))?,
217
55
        None => default_date,
218
    };
219
56
    Ok(Price {
220
56
        id,
221
56
        date,
222
56
        commodity_id,
223
56
        currency_id,
224
56
        commodity_split: Some(commodity_split),
225
56
        currency_split: Some(currency_split),
226
56
        value_num,
227
56
        value_denom,
228
56
    })
229
56
}
230

            
231
/// Returns the (keyword, value) pairs the plist carries. The outer list
232
/// is interpreted as a flat plist: every even-index element must be a
233
/// `Keyword`; the following element is its value.
234
2625
pub(super) fn expect_plist(expr: Expr, context: &str) -> Result<Vec<(String, Expr)>, String> {
235
2625
    let items = match expr {
236
2624
        Expr::List(items) => items,
237
1
        other => return Err(format!("{context}: expected plist, got {other:?}")),
238
    };
239
2624
    if !items.len().is_multiple_of(2) {
240
        return Err(format!(
241
            "{context}: plist has odd number of elements ({})",
242
            items.len()
243
        ));
244
2624
    }
245
2624
    let mut out = Vec::with_capacity(items.len() / 2);
246
2624
    let mut iter = items.into_iter();
247
10679
    while let Some(key) = iter.next() {
248
8055
        let key_name = match key {
249
8055
            Expr::Keyword(name) => name,
250
            other => return Err(format!("{context}: expected :keyword, got {other:?}")),
251
        };
252
8055
        let value = iter
253
8055
            .next()
254
8055
            .ok_or_else(|| format!("{context}: dangling :{key_name} without value"))?;
255
8055
        out.push((key_name, value));
256
    }
257
2624
    Ok(out)
258
2625
}
259

            
260
8829
pub(super) fn take_plist_string(
261
8829
    plist: &[(String, Expr)],
262
8829
    key: &str,
263
8829
) -> Result<Option<String>, String> {
264
8829
    let upper = key.to_ascii_uppercase();
265
18698
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
266
5343
        Some((_, Expr::String(s))) => Ok(Some(s.clone())),
267
        Some((_, Expr::Nil)) => Ok(None),
268
        Some((_, other)) => Err(format!(":{key} must be string, got {other:?}")),
269
3486
        None => Ok(None),
270
    }
271
8829
}
272

            
273
1800
pub(super) fn take_plist_number(
274
1800
    plist: &[(String, Expr)],
275
1800
    key: &str,
276
1800
) -> Result<Option<Fraction>, String> {
277
1800
    let upper = key.to_ascii_uppercase();
278
6177
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
279
1652
        Some((_, Expr::Number(n))) => Ok(Some(*n)),
280
        Some((_, Expr::Nil)) => Ok(None),
281
        Some((_, other)) => Err(format!(":{key} must be number, got {other:?}")),
282
148
        None => Ok(None),
283
    }
284
1800
}
285

            
286
1679
pub(super) fn take_plist_list(
287
1679
    plist: &[(String, Expr)],
288
1679
    key: &str,
289
1679
) -> Result<Option<Vec<Expr>>, String> {
290
1679
    let upper = key.to_ascii_uppercase();
291
4317
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
292
944
        Some((_, Expr::List(items))) => Ok(Some(items.clone())),
293
        Some((_, Expr::Nil)) => Ok(None),
294
        Some((_, other)) => Err(format!(":{key} must be list, got {other:?}")),
295
735
        None => Ok(None),
296
    }
297
1679
}
298

            
299
112
pub(super) fn take_plist_i64(plist: &[(String, Expr)], key: &str) -> Result<Option<i64>, String> {
300
112
    let upper = key.to_ascii_uppercase();
301
618
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
302
112
        Some((_, Expr::Number(n))) => {
303
112
            if *n.denom() == 1 {
304
112
                Ok(Some(*n.numer()))
305
            } else {
306
                Err(format!(":{key} must be integer, got {n}"))
307
            }
308
        }
309
        Some((_, Expr::Nil)) => Ok(None),
310
        Some((_, other)) => Err(format!(":{key} must be integer, got {other:?}")),
311
        None => Ok(None),
312
    }
313
112
}
314

            
315
57
pub(super) fn parse_transaction_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
316
57
    let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
317
1
        wasmtime::Error::msg("transaction id: missing or empty :transaction-id arg")
318
1
    })?;
319
56
    Uuid::parse_str(&raw)
320
56
        .map_err(|err| wasmtime::Error::msg(format!("transaction id: invalid uuid '{raw}': {err}")))
321
57
}
322

            
323
187
pub(super) fn parse_account_filter_arg(
324
187
    account_arg: Option<String>,
325
187
) -> wasmtime::Result<Option<Uuid>> {
326
187
    match account_arg.filter(|s| !s.is_empty()) {
327
28
        Some(s) => Uuid::parse_str(&s).map(Some).map_err(|err| {
328
1
            wasmtime::Error::msg(format!(
329
                "list-transactions: invalid account uuid '{s}': {err}"
330
            ))
331
1
        }),
332
159
        None => Ok(None),
333
    }
334
187
}
335

            
336
/// One logical (from→to) split in the logical transaction payload.
337
#[derive(Debug)]
338
pub(super) struct LogicalSplitInput {
339
    pub(super) from: String,
340
    pub(super) to: String,
341
    pub(super) from_commodity: String,
342
    pub(super) to_commodity: String,
343
    pub(super) value: Fraction,
344
    pub(super) to_amount: Option<Fraction>,
345
}
346

            
347
/// Parsed form of the `create-transaction-logical` payload.
348
#[derive(Debug)]
349
pub(super) struct LogicalTransactionInput {
350
    pub(super) note: Option<String>,
351
    pub(super) post_date: Option<DateTime<Utc>>,
352
    pub(super) splits: Vec<LogicalSplitInput>,
353
}
354

            
355
/// Parsed form of the `update-transaction-logical` payload.
356
#[derive(Debug)]
357
pub(super) struct LogicalTransactionUpdateInput {
358
    pub(super) transaction_id: Uuid,
359
    pub(super) note: Option<String>,
360
    pub(super) post_date: Option<DateTime<Utc>>,
361
    pub(super) splits: Vec<LogicalSplitInput>,
362
}
363

            
364
/// Parse a `:date` value tolerantly: RFC3339 (with tz), `YYYY-MM-DDTHH:MM[:SS]`
365
/// (no tz, treated as UTC), or bare `YYYY-MM-DD` (midnight UTC). The F3 form and
366
/// templates emit the latter two; rejecting them would break the form path.
367
6
fn parse_flexible_date(raw: &str) -> Result<DateTime<Utc>, String> {
368
6
    if let Ok(dt) = DateTime::parse_from_rfc3339(raw) {
369
1
        return Ok(dt.with_timezone(&Utc));
370
5
    }
371
9
    for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"] {
372
9
        if let Ok(naive) = NaiveDateTime::parse_from_str(raw, fmt) {
373
2
            return Ok(naive.and_utc());
374
7
        }
375
    }
376
3
    NaiveDate::parse_from_str(raw, "%Y-%m-%d")
377
3
        .ok()
378
3
        .and_then(|d| d.and_hms_opt(0, 0, 0))
379
3
        .map(|naive| naive.and_utc())
380
3
        .ok_or_else(|| format!("payload: invalid :date '{raw}'"))
381
6
}
382

            
383
121
pub(super) fn parse_create_transaction_logical_payload(
384
121
    src: &str,
385
121
) -> Result<LogicalTransactionInput, String> {
386
121
    let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
387
121
    let first = program
388
121
        .exprs
389
121
        .into_iter()
390
121
        .next()
391
121
        .ok_or_else(|| "payload empty".to_string())?;
392
121
    let plist = expect_plist(first, "payload")?;
393
121
    let note = take_plist_string(&plist, "note")?;
394
121
    let post_date = match take_plist_string(&plist, "date")? {
395
5
        Some(raw) => Some(parse_flexible_date(&raw)?),
396
116
        None => None,
397
    };
398
120
    let splits_list =
399
120
        take_plist_list(&plist, "splits")?.ok_or_else(|| "payload: missing :splits".to_string())?;
400
120
    if splits_list.is_empty() {
401
        return Err("payload: :splits must be non-empty".into());
402
120
    }
403
120
    let splits = splits_list
404
120
        .into_iter()
405
120
        .enumerate()
406
120
        .map(|(idx, e)| parse_logical_split_plist(e, idx))
407
120
        .collect::<Result<Vec<_>, _>>()?;
408
120
    Ok(LogicalTransactionInput {
409
120
        note,
410
120
        post_date,
411
120
        splits,
412
120
    })
413
121
}
414

            
415
33
pub(super) fn parse_update_transaction_logical_payload(
416
33
    src: &str,
417
33
) -> Result<LogicalTransactionUpdateInput, String> {
418
33
    let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
419
33
    let first = program
420
33
        .exprs
421
33
        .into_iter()
422
33
        .next()
423
33
        .ok_or_else(|| "payload empty".to_string())?;
424
33
    let plist = expect_plist(first, "payload")?;
425
33
    let tx_raw = take_plist_string(&plist, "transaction-id")?
426
33
        .ok_or_else(|| "payload: missing :transaction-id".to_string())?;
427
32
    let transaction_id = Uuid::parse_str(&tx_raw)
428
32
        .map_err(|err| format!("payload: invalid :transaction-id '{tx_raw}': {err}"))?;
429
32
    let note = take_plist_string(&plist, "note")?;
430
32
    let post_date = match take_plist_string(&plist, "date")? {
431
1
        Some(raw) => Some(parse_flexible_date(&raw)?),
432
31
        None => None,
433
    };
434
32
    let splits_list =
435
32
        take_plist_list(&plist, "splits")?.ok_or_else(|| "payload: missing :splits".to_string())?;
436
32
    if splits_list.is_empty() {
437
        return Err("payload: :splits must be non-empty".into());
438
32
    }
439
32
    let splits = splits_list
440
32
        .into_iter()
441
32
        .enumerate()
442
32
        .map(|(idx, e)| parse_logical_split_plist(e, idx))
443
32
        .collect::<Result<Vec<_>, _>>()?;
444
32
    Ok(LogicalTransactionUpdateInput {
445
32
        transaction_id,
446
32
        note,
447
32
        post_date,
448
32
        splits,
449
32
    })
450
33
}
451

            
452
152
fn parse_logical_split_plist(expr: Expr, idx: usize) -> Result<LogicalSplitInput, String> {
453
152
    let plist = expect_plist(expr, &format!("split[{idx}]"))?;
454
152
    let from =
455
152
        take_plist_string(&plist, "from")?.ok_or_else(|| format!("split[{idx}]: missing :from"))?;
456
152
    let to =
457
152
        take_plist_string(&plist, "to")?.ok_or_else(|| format!("split[{idx}]: missing :to"))?;
458
152
    let from_commodity = take_plist_string(&plist, "from-commodity")?
459
152
        .ok_or_else(|| format!("split[{idx}]: missing :from-commodity"))?;
460
152
    let to_commodity = take_plist_string(&plist, "to-commodity")?
461
152
        .ok_or_else(|| format!("split[{idx}]: missing :to-commodity"))?;
462
152
    let value = take_plist_number(&plist, "value")?
463
152
        .ok_or_else(|| format!("split[{idx}]: missing :value"))?;
464
152
    let to_amount = take_plist_number(&plist, "to-amount")?;
465
152
    Ok(LogicalSplitInput {
466
152
        from,
467
152
        to,
468
152
        from_commodity,
469
152
        to_commodity,
470
152
        value,
471
152
        to_amount,
472
152
    })
473
152
}