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
30
pub(super) fn parse_update_transaction_payload(
28
30
    src: &str,
29
30
) -> Result<UpdateTransactionInput, String> {
30
30
    let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
31
30
    let first = program
32
30
        .exprs
33
30
        .into_iter()
34
30
        .next()
35
30
        .ok_or_else(|| "payload empty".to_string())?;
36
30
    let plist = expect_plist(first, "payload")?;
37
30
    let tx_raw = take_plist_string(&plist, "transaction-id")?
38
30
        .ok_or_else(|| "payload: missing :transaction-id".to_string())?;
39
29
    let transaction_id = Uuid::parse_str(&tx_raw)
40
29
        .map_err(|err| format!("payload: invalid :transaction-id '{tx_raw}': {err}"))?;
41
29
    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
28
        None => None,
48
    };
49
29
    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
29
        None => None,
56
    };
57
29
    let note = take_plist_string(&plist, "note")?;
58
29
    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
27
        None => None,
71
    };
72
29
    let price_default_date = post_date.unwrap_or_else(Utc::now);
73
29
    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
27
        None => Vec::new(),
80
    };
81
29
    Ok(UpdateTransactionInput {
82
29
        transaction_id,
83
29
        post_date,
84
29
        enter_date,
85
29
        note,
86
29
        splits,
87
29
        prices,
88
29
    })
89
30
}
90

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

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

            
177
54
pub(super) fn parse_price_plist(
178
54
    expr: Expr,
179
54
    idx: usize,
180
54
    default_date: DateTime<Utc>,
181
54
) -> Result<Price, String> {
182
54
    let plist = expect_plist(expr, &format!("price[{idx}]"))?;
183
54
    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
54
        None => Uuid::new_v4(),
187
    };
188
54
    let commodity_raw = take_plist_string(&plist, "commodity-id")?
189
54
        .ok_or_else(|| format!("price[{idx}]: missing :commodity-id"))?;
190
54
    let commodity_id = Uuid::parse_str(&commodity_raw)
191
54
        .map_err(|err| format!("price[{idx}]: invalid :commodity-id '{commodity_raw}': {err}"))?;
192
54
    let currency_raw = take_plist_string(&plist, "currency-id")?
193
54
        .ok_or_else(|| format!("price[{idx}]: missing :currency-id"))?;
194
54
    let currency_id = Uuid::parse_str(&currency_raw)
195
54
        .map_err(|err| format!("price[{idx}]: invalid :currency-id '{currency_raw}': {err}"))?;
196
54
    let commodity_split_raw = take_plist_string(&plist, "commodity-split")?
197
54
        .ok_or_else(|| format!("price[{idx}]: missing :commodity-split"))?;
198
54
    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
54
    let currency_split_raw = take_plist_string(&plist, "currency-split")?
202
54
        .ok_or_else(|| format!("price[{idx}]: missing :currency-split"))?;
203
54
    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
54
    let value_num = take_plist_i64(&plist, "value-num")?
207
54
        .ok_or_else(|| format!("price[{idx}]: missing :value-num"))?;
208
54
    let value_denom = take_plist_i64(&plist, "value-denom")?
209
54
        .ok_or_else(|| format!("price[{idx}]: missing :value-denom"))?;
210
54
    if value_denom == 0 {
211
        return Err(format!("price[{idx}]: :value-denom must be non-zero"));
212
54
    }
213
54
    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
53
        None => default_date,
218
    };
219
54
    Ok(Price {
220
54
        id,
221
54
        date,
222
54
        commodity_id,
223
54
        currency_id,
224
54
        commodity_split: Some(commodity_split),
225
54
        currency_split: Some(currency_split),
226
54
        value_num,
227
54
        value_denom,
228
54
    })
229
54
}
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
2527
pub(super) fn expect_plist(expr: Expr, context: &str) -> Result<Vec<(String, Expr)>, String> {
235
2527
    let items = match expr {
236
2526
        Expr::List(items) => items,
237
1
        other => return Err(format!("{context}: expected plist, got {other:?}")),
238
    };
239
2526
    if !items.len().is_multiple_of(2) {
240
        return Err(format!(
241
            "{context}: plist has odd number of elements ({})",
242
            items.len()
243
        ));
244
2526
    }
245
2526
    let mut out = Vec::with_capacity(items.len() / 2);
246
2526
    let mut iter = items.into_iter();
247
10281
    while let Some(key) = iter.next() {
248
7755
        let key_name = match key {
249
7755
            Expr::Keyword(name) => name,
250
            other => return Err(format!("{context}: expected :keyword, got {other:?}")),
251
        };
252
7755
        let value = iter
253
7755
            .next()
254
7755
            .ok_or_else(|| format!("{context}: dangling :{key_name} without value"))?;
255
7755
        out.push((key_name, value));
256
    }
257
2526
    Ok(out)
258
2527
}
259

            
260
8499
pub(super) fn take_plist_string(
261
8499
    plist: &[(String, Expr)],
262
8499
    key: &str,
263
8499
) -> Result<Option<String>, String> {
264
8499
    let upper = key.to_ascii_uppercase();
265
18000
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
266
5144
        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
3355
        None => Ok(None),
270
    }
271
8499
}
272

            
273
1733
pub(super) fn take_plist_number(
274
1733
    plist: &[(String, Expr)],
275
1733
    key: &str,
276
1733
) -> Result<Option<Fraction>, String> {
277
1733
    let upper = key.to_ascii_uppercase();
278
5950
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
279
1590
        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
143
        None => Ok(None),
283
    }
284
1733
}
285

            
286
1616
pub(super) fn take_plist_list(
287
1616
    plist: &[(String, Expr)],
288
1616
    key: &str,
289
1616
) -> Result<Option<Vec<Expr>>, String> {
290
1616
    let upper = key.to_ascii_uppercase();
291
4154
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
292
909
        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
707
        None => Ok(None),
296
    }
297
1616
}
298

            
299
108
pub(super) fn take_plist_i64(plist: &[(String, Expr)], key: &str) -> Result<Option<i64>, String> {
300
108
    let upper = key.to_ascii_uppercase();
301
596
    match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
302
108
        Some((_, Expr::Number(n))) => {
303
108
            if *n.denom() == 1 {
304
108
                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
108
}
314

            
315
55
pub(super) fn parse_transaction_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
316
55
    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
54
    Uuid::parse_str(&raw)
320
54
        .map_err(|err| wasmtime::Error::msg(format!("transaction id: invalid uuid '{raw}': {err}")))
321
55
}
322

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

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

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