1
//! Maps a rendered [`rpc::TransactionDraft`] (a flat list of signed splits)
2
//! onto the create form's transfer-row model (each row is a from→to pair that
3
//! `process_split_data` later expands back into two splits).
4
//!
5
//! **Lossless-or-nothing.** The form's transfer-row model is strictly LESS
6
//! expressive than a flat split list, so a naive sign-pairing would invent
7
//! structure that submitting can't reconstruct — silently persisting a
8
//! different transaction than the template rendered. To never do that,
9
//! [`draft_to_prefilled`] only accepts drafts it can represent EXACTLY and
10
//! returns `None` otherwise (the create page then simply shows no prefill). The
11
//! accepted shape matches how favourite-transaction templates are written:
12
//! consecutive `(from −x)` then `(to +x)` `draft-split` pairs, each amount an
13
//! exact terminating decimal, and same-commodity pairs balancing to equal
14
//! magnitude. Ambiguous 1→N drafts, non-terminating ratios (`1/3`), and
15
//! unparseable dates all fail closed rather than degrade.
16

            
17
use serde::Serialize;
18

            
19
/// One transfer row for the create form. Account/commodity are uuids; the
20
/// browser resolves display names via `/api/account/list` (as the
21
/// from-account prefill already does), so the server side stays name-free.
22
#[derive(Debug, Serialize, PartialEq, Eq)]
23
pub struct PrefilledRow {
24
    pub amount: String,
25
    pub from_account: String,
26
    pub from_commodity: String,
27
    pub to_account: String,
28
    pub to_commodity: String,
29
    /// Set only when the two legs carry different commodities (a conversion).
30
    pub amount_converted: Option<String>,
31
    /// Tags the template attached to the debit (from) leg of this row.
32
    pub from_tags: Vec<PrefilledTag>,
33
    /// Tags the template attached to the credit (to) leg of this row.
34
    pub to_tags: Vec<PrefilledTag>,
35
}
36

            
37
#[derive(Debug, Serialize, PartialEq, Eq)]
38
pub struct PrefilledTag {
39
    pub name: String,
40
    pub value: String,
41
}
42

            
43
#[derive(Debug, Serialize, PartialEq, Eq, Default)]
44
pub struct PrefilledDraft {
45
    pub note: Option<String>,
46
    pub date: Option<String>,
47
    pub rows: Vec<PrefilledRow>,
48
    pub tags: Vec<PrefilledTag>,
49
}
50

            
51
/// Most fractional digits the create form's submit path can round-trip. It
52
/// rebuilds the rational as `10_i64.pow(decimals)`, which overflows `i64` past
53
/// ~18 digits, so cap below that with margin. A terminating decimal needing
54
/// more places than this is rejected rather than risk an overflowing/lossy
55
/// round-trip on submit.
56
const MAX_FRACTIONAL_DIGITS: usize = 15;
57

            
58
/// Formats `|num/denom|` as an EXACT decimal string the create form accepts
59
/// (it parses integers and dotted decimals, then back to an exact rational for
60
/// storage). Returns `None` when the value is not a terminating decimal (the
61
/// reduced denominator has a prime factor other than 2 or 5, e.g. `1/3`), needs
62
/// more than [`MAX_FRACTIONAL_DIGITS`] places, or would overflow during digit
63
/// expansion — so a non-round-trippable amount fails the prefill instead of
64
/// being silently rounded/corrupted before persistence.
65
44
fn exact_decimal(num: i64, denom: i64) -> Option<String> {
66
44
    if denom == 0 {
67
2
        return None;
68
42
    }
69
42
    let mut n = num.unsigned_abs();
70
42
    let mut d = denom.unsigned_abs();
71
42
    let g = gcd(n, d);
72
42
    n /= g;
73
42
    d /= g;
74
    // Strip all 2s and 5s from the reduced denominator; whatever remains makes
75
    // the decimal non-terminating.
76
42
    let mut reduced = d;
77
196
    while reduced.is_multiple_of(2) {
78
154
        reduced /= 2;
79
154
    }
80
44
    while reduced.is_multiple_of(5) {
81
2
        reduced /= 5;
82
2
    }
83
42
    if reduced != 1 {
84
6
        return None;
85
36
    }
86
36
    let int_part = n / d;
87
36
    let mut rem = n % d;
88
36
    if rem == 0 {
89
24
        let s = int_part.to_string();
90
24
        return submit_round_trips(&s).then_some(s);
91
12
    }
92
12
    let mut frac = String::new();
93
76
    while rem != 0 {
94
66
        if frac.len() >= MAX_FRACTIONAL_DIGITS {
95
2
            return None;
96
64
        }
97
        // Checked: a huge reduced denominator could overflow `rem * 10`.
98
64
        rem = rem.checked_mul(10)?;
99
64
        frac.push(char::from(b'0' + (rem / d) as u8));
100
64
        rem %= d;
101
    }
102
10
    let s = format!("{int_part}.{frac}");
103
10
    submit_round_trips(&s).then_some(s)
104
44
}
105

            
106
/// Whether the create form's submit parser (`parse_amount_to_rational`) can
107
/// rebuild this decimal string WITHOUT overflow: it strips the dot and parses
108
/// the whole thing as an `i64` numerator over `10^decimals`. A large integer
109
/// part can push the dot-stripped numerator past `i64::MAX` even within the
110
/// scale cap, so verify both fit before offering the value as prefill — keeping
111
/// the "lossless-or-nothing" guarantee through to persistence.
112
34
fn submit_round_trips(decimal: &str) -> bool {
113
140
    let digits: String = decimal.chars().filter(|c| *c != '.').collect();
114
34
    if digits.parse::<i64>().is_err() {
115
2
        return false;
116
32
    }
117
32
    if let Some(dot) = decimal.find('.') {
118
8
        let decimals = (decimal.len() - dot - 1) as u32;
119
8
        if 10_i64.checked_pow(decimals).is_none() {
120
            return false;
121
8
        }
122
24
    }
123
32
    true
124
34
}
125

            
126
42
fn gcd(mut a: u64, mut b: u64) -> u64 {
127
104
    while b != 0 {
128
62
        (a, b) = (b, a % b);
129
62
    }
130
42
    a.max(1)
131
42
}
132

            
133
/// Normalizes a template-supplied date to the `datetime-local` form
134
/// (`YYYY-MM-DDTHH:MM`) the create page expects. Accepts a bare date
135
/// (`YYYY-MM-DD` → midnight), a `datetime-local` value, or RFC3339. Returns
136
/// `None` for anything else so a malformed template date fails the prefill
137
/// rather than silently submitting as "now".
138
///
139
/// An RFC3339 value with an offset is reduced to its UTC instant (`naive_utc`)
140
/// — NOT local time — because the submit path stamps the resubmitted naive
141
/// datetime with `.and_utc()`. Using UTC here keeps the persisted instant equal
142
/// to the template's, instead of shifting it by the server's timezone.
143
18
fn normalize_date(raw: &str) -> Option<String> {
144
    use chrono::{NaiveDate, NaiveDateTime};
145
18
    let raw = raw.trim();
146
18
    if let Ok(d) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
147
4
        return Some(d.format("%Y-%m-%dT00:00").to_string());
148
14
    }
149
24
    for fmt in ["%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"] {
150
24
        if let Ok(dt) = NaiveDateTime::parse_from_str(raw, fmt) {
151
6
            return Some(dt.format("%Y-%m-%dT%H:%M").to_string());
152
18
        }
153
    }
154
8
    chrono::DateTime::parse_from_rfc3339(raw)
155
8
        .ok()
156
8
        .map(|dt| dt.naive_utc().format("%Y-%m-%dT%H:%M").to_string())
157
18
}
158

            
159
/// Serializes a value to JSON safe for inlining inside an HTML `<script>`
160
/// block. `serde_json` does not escape `<`, `>`, or `&`, so a string field
161
/// containing `</script>` would otherwise break out of the tag (a template
162
/// author controls the note/tag text → stored self-XSS). Escaping `<`/`>`/`&`
163
/// to their `\uXXXX` forms is still valid JSON that `JSON.parse` /
164
/// `window.x = …` reads identically, and cannot terminate the script element.
165
2
pub fn to_script_safe_json<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
166
2
    let json = serde_json::to_string(value)?;
167
2
    Ok(json
168
2
        .replace('<', "\\u003c")
169
2
        .replace('>', "\\u003e")
170
2
        .replace('&', "\\u0026"))
171
2
}
172

            
173
/// Pairs consecutive `(from, to)` draft splits into one exactly-representable
174
/// transfer row, or `None` if the pair can't be losslessly shown as a form row.
175
14
fn pair_to_row(from: &rpc::DraftSplit, to: &rpc::DraftSplit) -> Option<PrefilledRow> {
176
    // First leg must be the debit (money out), second the credit (money in) —
177
    // the order favourite-transaction templates write them.
178
14
    if from.value_num >= 0 || to.value_num < 0 {
179
2
        return None;
180
12
    }
181
12
    let from_amount = exact_decimal(from.value_num, from.value_denom)?;
182
10
    let to_amount = exact_decimal(to.value_num, to.value_denom)?;
183

            
184
10
    let amount_converted = if from.commodity_id == to.commodity_id {
185
        // Same-commodity transfer: the form forces the credit to equal the
186
        // debit, so a draft whose legs disagree can't be represented here.
187
8
        if from_amount != to_amount {
188
2
            return None;
189
6
        }
190
6
        None
191
    } else {
192
2
        Some(to_amount)
193
    };
194

            
195
8
    Some(PrefilledRow {
196
8
        amount: from_amount,
197
8
        from_account: from.account_id.clone(),
198
8
        from_commodity: from.commodity_id.clone(),
199
8
        to_account: to.account_id.clone(),
200
8
        to_commodity: to.commodity_id.clone(),
201
8
        amount_converted,
202
8
        from_tags: convert_tags(&from.tags),
203
8
        to_tags: convert_tags(&to.tags),
204
8
    })
205
14
}
206

            
207
/// Builds a prefill for the create form, or `None` when the draft cannot be
208
/// represented EXACTLY as transfer rows (ambiguous split structure,
209
/// non-terminating amount, or unparseable date). Returning `None` means "show
210
/// no prefill" — never a silently-altered transaction.
211
#[must_use]
212
20
pub fn draft_to_prefilled(draft: rpc::TransactionDraft) -> Option<PrefilledDraft> {
213
    let rpc::TransactionDraft {
214
20
        note,
215
20
        date,
216
20
        splits,
217
20
        tags,
218
20
    } = draft;
219

            
220
    // Splits must form consecutive debit→credit pairs.
221
20
    if splits.len() % 2 != 0 {
222
2
        return None;
223
18
    }
224
18
    let mut rows = Vec::with_capacity(splits.len() / 2);
225
18
    for pair in splits.chunks_exact(2) {
226
14
        rows.push(pair_to_row(&pair[0], &pair[1])?);
227
    }
228

            
229
12
    let date = match date {
230
4
        Some(raw) => Some(normalize_date(&raw)?),
231
8
        None => None,
232
    };
233

            
234
10
    Some(PrefilledDraft {
235
10
        note,
236
10
        date,
237
10
        rows,
238
10
        tags: convert_tags(&tags),
239
10
    })
240
20
}
241

            
242
/// Maps render-layer draft tags to the prefill's serializable tag shape.
243
26
fn convert_tags(tags: &[rpc::DraftTag]) -> Vec<PrefilledTag> {
244
26
    tags.iter()
245
26
        .map(|t| PrefilledTag {
246
6
            name: t.name.clone(),
247
6
            value: t.value.clone(),
248
6
        })
249
26
        .collect()
250
26
}
251

            
252
#[cfg(test)]
253
mod tests {
254
    use super::*;
255
    use rpc::{DraftSplit, DraftTag, TransactionDraft};
256

            
257
34
    fn split(account: &str, commodity: &str, num: i64, denom: i64) -> DraftSplit {
258
34
        DraftSplit {
259
34
            account_id: account.to_string(),
260
34
            commodity_id: commodity.to_string(),
261
34
            value_num: num,
262
34
            value_denom: denom,
263
34
            tags: vec![],
264
34
        }
265
34
    }
266

            
267
4
    fn tag(name: &str, value: &str) -> DraftTag {
268
4
        DraftTag {
269
4
            name: name.to_string(),
270
4
            value: value.to_string(),
271
4
        }
272
4
    }
273

            
274
    #[test]
275
2
    fn exact_decimal_terminating_only() {
276
2
        assert_eq!(exact_decimal(-50, 1).as_deref(), Some("50"));
277
2
        assert_eq!(exact_decimal(505, 100).as_deref(), Some("5.05"));
278
2
        assert_eq!(exact_decimal(-1, 2).as_deref(), Some("0.5"));
279
2
        assert_eq!(exact_decimal(100, 1).as_deref(), Some("100"));
280
2
        assert_eq!(exact_decimal(1, 8).as_deref(), Some("0.125"));
281
        // Non-terminating: must fail closed.
282
2
        assert_eq!(exact_decimal(1, 3), None);
283
2
        assert_eq!(exact_decimal(2, 7), None);
284
2
        assert_eq!(exact_decimal(1, 0), None);
285
2
    }
286

            
287
    #[test]
288
2
    fn exact_decimal_rejects_unrepresentable_scale() {
289
        // 1 / 2^60 is a terminating decimal in theory but needs 60 fractional
290
        // digits — past what the form's submit path can round-trip — so it must
291
        // fail closed rather than emit a value that overflows on reparse.
292
2
        let huge_denom = 1_i64 << 60;
293
2
        assert_eq!(exact_decimal(1, huge_denom), None);
294
        // 1/1024 = 0.0009765625 is exactly 10 digits — within bounds.
295
2
        assert_eq!(exact_decimal(1, 1024).as_deref(), Some("0.0009765625"));
296
2
    }
297

            
298
    #[test]
299
2
    fn exact_decimal_rejects_submit_numerator_overflow() {
300
        // i64::MAX / 2 is `…3.5` — only one fractional digit (within the scale
301
        // cap), but the submit parser strips the dot and parses a 20-digit
302
        // numerator, which overflows i64. Must fail closed, not render-then-fail.
303
2
        assert_eq!(exact_decimal(i64::MAX, 2), None);
304
        // The submit-side round-trip really would reject it (sanity on the
305
        // parser used by the form).
306
2
        let dot_stripped = format!("{}5", i64::MAX / 2);
307
2
        assert!(dot_stripped.parse::<i64>().is_err());
308
2
    }
309

            
310
    #[test]
311
2
    fn rfc3339_date_normalizes_to_utc_instant() {
312
        // A +02:00 offset must reduce to the UTC instant (07:30), because the
313
        // submit path re-stamps the naive value as UTC — using local time would
314
        // shift the persisted instant by the server timezone.
315
2
        assert_eq!(
316
2
            normalize_date("2026-06-15T09:30:00+02:00").as_deref(),
317
            Some("2026-06-15T07:30")
318
        );
319
        // A plain datetime-local is preserved verbatim (already wall-clock).
320
2
        assert_eq!(
321
2
            normalize_date("2026-06-15T09:30").as_deref(),
322
            Some("2026-06-15T09:30")
323
        );
324
2
    }
325

            
326
    #[test]
327
2
    fn normalize_date_accepts_known_shapes_else_none() {
328
2
        assert_eq!(
329
2
            normalize_date("2026-06-15").as_deref(),
330
            Some("2026-06-15T00:00")
331
        );
332
2
        assert_eq!(
333
2
            normalize_date("2026-06-15T09:30").as_deref(),
334
            Some("2026-06-15T09:30")
335
        );
336
2
        assert_eq!(
337
2
            normalize_date("2026-06-15T09:30:45").as_deref(),
338
            Some("2026-06-15T09:30")
339
        );
340
2
        assert!(normalize_date("yesterday").is_none());
341
2
        assert!(normalize_date("06/15/2026").is_none());
342
2
    }
343

            
344
    #[test]
345
2
    fn balanced_two_split_draft_yields_one_row() {
346
2
        let draft = TransactionDraft {
347
2
            note: Some("Groceries".into()),
348
2
            date: Some("2026-06-15".into()),
349
2
            splits: vec![
350
2
                split("checking", "usd", -50, 1),
351
2
                split("food", "usd", 50, 1),
352
2
            ],
353
2
            tags: vec![DraftTag {
354
2
                name: "category".into(),
355
2
                value: "food".into(),
356
2
            }],
357
2
        };
358

            
359
2
        let pre = draft_to_prefilled(draft).expect("balanced transfer is representable");
360
2
        assert_eq!(pre.note.as_deref(), Some("Groceries"));
361
2
        assert_eq!(pre.date.as_deref(), Some("2026-06-15T00:00"));
362
2
        assert_eq!(pre.rows.len(), 1);
363
2
        let row = &pre.rows[0];
364
2
        assert_eq!(row.from_account, "checking");
365
2
        assert_eq!(row.to_account, "food");
366
2
        assert_eq!(row.amount, "50");
367
2
        assert_eq!(row.amount_converted, None);
368
2
        assert_eq!(pre.tags.len(), 1);
369
2
        assert_eq!(pre.tags[0].name, "category");
370
2
    }
371

            
372
    #[test]
373
2
    fn split_tags_map_to_legs_by_sign() {
374
        // A tag on the debit (negative) split lands on the row's from-leg; a tag
375
        // on the credit (positive) split lands on the to-leg.
376
2
        let mut from = split("checking", "usd", -50, 1);
377
2
        from.tags.push(tag("memo", "rent"));
378
2
        let mut to = split("housing", "usd", 50, 1);
379
2
        to.tags.push(tag("class", "fixed"));
380

            
381
2
        let draft = TransactionDraft {
382
2
            note: None,
383
2
            date: None,
384
2
            splits: vec![from, to],
385
2
            tags: vec![],
386
2
        };
387

            
388
2
        let pre = draft_to_prefilled(draft).expect("representable");
389
2
        let row = &pre.rows[0];
390
2
        assert_eq!(row.from_tags.len(), 1);
391
2
        assert_eq!(row.from_tags[0].name, "memo");
392
2
        assert_eq!(row.from_tags[0].value, "rent");
393
2
        assert_eq!(row.to_tags.len(), 1);
394
2
        assert_eq!(row.to_tags[0].name, "class");
395
2
        assert_eq!(row.to_tags[0].value, "fixed");
396
2
    }
397

            
398
    #[test]
399
2
    fn cross_commodity_pair_sets_amount_converted() {
400
2
        let draft = TransactionDraft {
401
2
            note: None,
402
2
            date: None,
403
2
            splits: vec![
404
2
                split("wallet", "usd", -50, 1),
405
2
                split("shop", "jpy", 7500, 1),
406
2
            ],
407
2
            tags: vec![],
408
2
        };
409

            
410
2
        let pre = draft_to_prefilled(draft).expect("conversion transfer is representable");
411
2
        assert_eq!(pre.rows.len(), 1);
412
2
        let row = &pre.rows[0];
413
2
        assert_eq!(row.amount, "50");
414
2
        assert_eq!(row.from_commodity, "usd");
415
2
        assert_eq!(row.to_commodity, "jpy");
416
2
        assert_eq!(row.amount_converted.as_deref(), Some("7500"));
417
2
    }
418

            
419
    #[test]
420
2
    fn ambiguous_one_to_n_draft_is_rejected() {
421
        // One debit, two credits — not losslessly representable as transfer
422
        // rows (the 3rd split would have no debit partner), so fail closed
423
        // rather than invent structure that submits a different transaction.
424
2
        let draft = TransactionDraft {
425
2
            note: None,
426
2
            date: None,
427
2
            splits: vec![
428
2
                split("checking", "usd", -100, 1),
429
2
                split("food", "usd", 60, 1),
430
2
                split("rent", "usd", 40, 1),
431
2
            ],
432
2
            tags: vec![],
433
2
        };
434
2
        assert!(draft_to_prefilled(draft).is_none());
435
2
    }
436

            
437
    #[test]
438
2
    fn same_commodity_unbalanced_pair_is_rejected() {
439
        // A same-commodity row's credit is forced to equal its debit by the
440
        // form, so a pair whose legs disagree can't be shown losslessly.
441
2
        let draft = TransactionDraft {
442
2
            note: None,
443
2
            date: None,
444
2
            splits: vec![split("a", "usd", -50, 1), split("b", "usd", 40, 1)],
445
2
            tags: vec![],
446
2
        };
447
2
        assert!(draft_to_prefilled(draft).is_none());
448
2
    }
449

            
450
    #[test]
451
2
    fn non_terminating_amount_is_rejected() {
452
        // 1/3 USD can't be an exact decimal the form round-trips, so reject
453
        // rather than persist a truncated 0.333333.
454
2
        let draft = TransactionDraft {
455
2
            note: None,
456
2
            date: None,
457
2
            splits: vec![split("a", "usd", -1, 3), split("b", "usd", 1, 3)],
458
2
            tags: vec![],
459
2
        };
460
2
        assert!(draft_to_prefilled(draft).is_none());
461
2
    }
462

            
463
    #[test]
464
2
    fn unparseable_date_is_rejected() {
465
2
        let draft = TransactionDraft {
466
2
            note: None,
467
2
            date: Some("someday".into()),
468
2
            splits: vec![split("a", "usd", -1, 1), split("b", "usd", 1, 1)],
469
2
            tags: vec![],
470
2
        };
471
2
        assert!(draft_to_prefilled(draft).is_none());
472
2
    }
473

            
474
    #[test]
475
2
    fn wrong_leg_order_is_rejected() {
476
        // Credit before debit isn't the from→to shape the form expects.
477
2
        let draft = TransactionDraft {
478
2
            note: None,
479
2
            date: None,
480
2
            splits: vec![split("a", "usd", 50, 1), split("b", "usd", -50, 1)],
481
2
            tags: vec![],
482
2
        };
483
2
        assert!(draft_to_prefilled(draft).is_none());
484
2
    }
485

            
486
    #[test]
487
2
    fn empty_draft_yields_empty_prefill() {
488
2
        let pre = draft_to_prefilled(TransactionDraft::default()).expect("empty is representable");
489
2
        assert!(pre.rows.is_empty());
490
2
        assert!(pre.note.is_none());
491
2
        assert!(pre.tags.is_empty());
492
2
    }
493

            
494
    #[test]
495
2
    fn script_safe_json_escapes_script_breakout() {
496
        // A template author who sets a note containing `</script>` must not be
497
        // able to break out of the inline <script> the create page emits.
498
2
        let draft = TransactionDraft {
499
2
            note: Some("</script><img src=x onerror=alert(1)>".into()),
500
2
            date: None,
501
2
            splits: vec![],
502
2
            tags: vec![],
503
2
        };
504
2
        let pre = draft_to_prefilled(draft).expect("note-only draft is representable");
505
2
        let json = to_script_safe_json(&pre).unwrap();
506
2
        assert!(
507
2
            !json.contains("</script>"),
508
            "raw </script> must not survive: {json}"
509
        );
510
2
        assert!(!json.contains('<') && !json.contains('>') && !json.contains('&'));
511
2
        assert!(
512
2
            json.contains("\\u003c"),
513
            "< must be \\u003c-escaped: {json}"
514
        );
515
        // Still valid JSON that round-trips to the original text.
516
2
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
517
2
        assert_eq!(
518
2
            parsed["note"], "</script><img src=x onerror=alert(1)>",
519
            "escaped JSON must parse back to the original note"
520
        );
521
2
    }
522
}