Skip to main content

web/pages/transaction/create/
prefill.rs

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
17use 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)]
23pub 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)]
38pub struct PrefilledTag {
39    pub name: String,
40    pub value: String,
41}
42
43#[derive(Debug, Serialize, PartialEq, Eq, Default)]
44pub 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.
56const 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.
65fn exact_decimal(num: i64, denom: i64) -> Option<String> {
66    if denom == 0 {
67        return None;
68    }
69    let mut n = num.unsigned_abs();
70    let mut d = denom.unsigned_abs();
71    let g = gcd(n, d);
72    n /= g;
73    d /= g;
74    // Strip all 2s and 5s from the reduced denominator; whatever remains makes
75    // the decimal non-terminating.
76    let mut reduced = d;
77    while reduced.is_multiple_of(2) {
78        reduced /= 2;
79    }
80    while reduced.is_multiple_of(5) {
81        reduced /= 5;
82    }
83    if reduced != 1 {
84        return None;
85    }
86    let int_part = n / d;
87    let mut rem = n % d;
88    if rem == 0 {
89        let s = int_part.to_string();
90        return submit_round_trips(&s).then_some(s);
91    }
92    let mut frac = String::new();
93    while rem != 0 {
94        if frac.len() >= MAX_FRACTIONAL_DIGITS {
95            return None;
96        }
97        // Checked: a huge reduced denominator could overflow `rem * 10`.
98        rem = rem.checked_mul(10)?;
99        frac.push(char::from(b'0' + (rem / d) as u8));
100        rem %= d;
101    }
102    let s = format!("{int_part}.{frac}");
103    submit_round_trips(&s).then_some(s)
104}
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.
112fn submit_round_trips(decimal: &str) -> bool {
113    let digits: String = decimal.chars().filter(|c| *c != '.').collect();
114    if digits.parse::<i64>().is_err() {
115        return false;
116    }
117    if let Some(dot) = decimal.find('.') {
118        let decimals = (decimal.len() - dot - 1) as u32;
119        if 10_i64.checked_pow(decimals).is_none() {
120            return false;
121        }
122    }
123    true
124}
125
126fn gcd(mut a: u64, mut b: u64) -> u64 {
127    while b != 0 {
128        (a, b) = (b, a % b);
129    }
130    a.max(1)
131}
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.
143fn normalize_date(raw: &str) -> Option<String> {
144    use chrono::{NaiveDate, NaiveDateTime};
145    let raw = raw.trim();
146    if let Ok(d) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
147        return Some(d.format("%Y-%m-%dT00:00").to_string());
148    }
149    for fmt in ["%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"] {
150        if let Ok(dt) = NaiveDateTime::parse_from_str(raw, fmt) {
151            return Some(dt.format("%Y-%m-%dT%H:%M").to_string());
152        }
153    }
154    chrono::DateTime::parse_from_rfc3339(raw)
155        .ok()
156        .map(|dt| dt.naive_utc().format("%Y-%m-%dT%H:%M").to_string())
157}
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.
165pub fn to_script_safe_json<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
166    let json = serde_json::to_string(value)?;
167    Ok(json
168        .replace('<', "\\u003c")
169        .replace('>', "\\u003e")
170        .replace('&', "\\u0026"))
171}
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.
175fn 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    if from.value_num >= 0 || to.value_num < 0 {
179        return None;
180    }
181    let from_amount = exact_decimal(from.value_num, from.value_denom)?;
182    let to_amount = exact_decimal(to.value_num, to.value_denom)?;
183
184    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        if from_amount != to_amount {
188            return None;
189        }
190        None
191    } else {
192        Some(to_amount)
193    };
194
195    Some(PrefilledRow {
196        amount: from_amount,
197        from_account: from.account_id.clone(),
198        from_commodity: from.commodity_id.clone(),
199        to_account: to.account_id.clone(),
200        to_commodity: to.commodity_id.clone(),
201        amount_converted,
202        from_tags: convert_tags(&from.tags),
203        to_tags: convert_tags(&to.tags),
204    })
205}
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]
212pub fn draft_to_prefilled(draft: rpc::TransactionDraft) -> Option<PrefilledDraft> {
213    let rpc::TransactionDraft {
214        note,
215        date,
216        splits,
217        tags,
218    } = draft;
219
220    // Splits must form consecutive debit→credit pairs.
221    if splits.len() % 2 != 0 {
222        return None;
223    }
224    let mut rows = Vec::with_capacity(splits.len() / 2);
225    for pair in splits.chunks_exact(2) {
226        rows.push(pair_to_row(&pair[0], &pair[1])?);
227    }
228
229    let date = match date {
230        Some(raw) => Some(normalize_date(&raw)?),
231        None => None,
232    };
233
234    Some(PrefilledDraft {
235        note,
236        date,
237        rows,
238        tags: convert_tags(&tags),
239    })
240}
241
242/// Maps render-layer draft tags to the prefill's serializable tag shape.
243fn convert_tags(tags: &[rpc::DraftTag]) -> Vec<PrefilledTag> {
244    tags.iter()
245        .map(|t| PrefilledTag {
246            name: t.name.clone(),
247            value: t.value.clone(),
248        })
249        .collect()
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use rpc::{DraftSplit, DraftTag, TransactionDraft};
256
257    fn split(account: &str, commodity: &str, num: i64, denom: i64) -> DraftSplit {
258        DraftSplit {
259            account_id: account.to_string(),
260            commodity_id: commodity.to_string(),
261            value_num: num,
262            value_denom: denom,
263            tags: vec![],
264        }
265    }
266
267    fn tag(name: &str, value: &str) -> DraftTag {
268        DraftTag {
269            name: name.to_string(),
270            value: value.to_string(),
271        }
272    }
273
274    #[test]
275    fn exact_decimal_terminating_only() {
276        assert_eq!(exact_decimal(-50, 1).as_deref(), Some("50"));
277        assert_eq!(exact_decimal(505, 100).as_deref(), Some("5.05"));
278        assert_eq!(exact_decimal(-1, 2).as_deref(), Some("0.5"));
279        assert_eq!(exact_decimal(100, 1).as_deref(), Some("100"));
280        assert_eq!(exact_decimal(1, 8).as_deref(), Some("0.125"));
281        // Non-terminating: must fail closed.
282        assert_eq!(exact_decimal(1, 3), None);
283        assert_eq!(exact_decimal(2, 7), None);
284        assert_eq!(exact_decimal(1, 0), None);
285    }
286
287    #[test]
288    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        let huge_denom = 1_i64 << 60;
293        assert_eq!(exact_decimal(1, huge_denom), None);
294        // 1/1024 = 0.0009765625 is exactly 10 digits — within bounds.
295        assert_eq!(exact_decimal(1, 1024).as_deref(), Some("0.0009765625"));
296    }
297
298    #[test]
299    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        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        let dot_stripped = format!("{}5", i64::MAX / 2);
307        assert!(dot_stripped.parse::<i64>().is_err());
308    }
309
310    #[test]
311    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        assert_eq!(
316            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        assert_eq!(
321            normalize_date("2026-06-15T09:30").as_deref(),
322            Some("2026-06-15T09:30")
323        );
324    }
325
326    #[test]
327    fn normalize_date_accepts_known_shapes_else_none() {
328        assert_eq!(
329            normalize_date("2026-06-15").as_deref(),
330            Some("2026-06-15T00:00")
331        );
332        assert_eq!(
333            normalize_date("2026-06-15T09:30").as_deref(),
334            Some("2026-06-15T09:30")
335        );
336        assert_eq!(
337            normalize_date("2026-06-15T09:30:45").as_deref(),
338            Some("2026-06-15T09:30")
339        );
340        assert!(normalize_date("yesterday").is_none());
341        assert!(normalize_date("06/15/2026").is_none());
342    }
343
344    #[test]
345    fn balanced_two_split_draft_yields_one_row() {
346        let draft = TransactionDraft {
347            note: Some("Groceries".into()),
348            date: Some("2026-06-15".into()),
349            splits: vec![
350                split("checking", "usd", -50, 1),
351                split("food", "usd", 50, 1),
352            ],
353            tags: vec![DraftTag {
354                name: "category".into(),
355                value: "food".into(),
356            }],
357        };
358
359        let pre = draft_to_prefilled(draft).expect("balanced transfer is representable");
360        assert_eq!(pre.note.as_deref(), Some("Groceries"));
361        assert_eq!(pre.date.as_deref(), Some("2026-06-15T00:00"));
362        assert_eq!(pre.rows.len(), 1);
363        let row = &pre.rows[0];
364        assert_eq!(row.from_account, "checking");
365        assert_eq!(row.to_account, "food");
366        assert_eq!(row.amount, "50");
367        assert_eq!(row.amount_converted, None);
368        assert_eq!(pre.tags.len(), 1);
369        assert_eq!(pre.tags[0].name, "category");
370    }
371
372    #[test]
373    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        let mut from = split("checking", "usd", -50, 1);
377        from.tags.push(tag("memo", "rent"));
378        let mut to = split("housing", "usd", 50, 1);
379        to.tags.push(tag("class", "fixed"));
380
381        let draft = TransactionDraft {
382            note: None,
383            date: None,
384            splits: vec![from, to],
385            tags: vec![],
386        };
387
388        let pre = draft_to_prefilled(draft).expect("representable");
389        let row = &pre.rows[0];
390        assert_eq!(row.from_tags.len(), 1);
391        assert_eq!(row.from_tags[0].name, "memo");
392        assert_eq!(row.from_tags[0].value, "rent");
393        assert_eq!(row.to_tags.len(), 1);
394        assert_eq!(row.to_tags[0].name, "class");
395        assert_eq!(row.to_tags[0].value, "fixed");
396    }
397
398    #[test]
399    fn cross_commodity_pair_sets_amount_converted() {
400        let draft = TransactionDraft {
401            note: None,
402            date: None,
403            splits: vec![
404                split("wallet", "usd", -50, 1),
405                split("shop", "jpy", 7500, 1),
406            ],
407            tags: vec![],
408        };
409
410        let pre = draft_to_prefilled(draft).expect("conversion transfer is representable");
411        assert_eq!(pre.rows.len(), 1);
412        let row = &pre.rows[0];
413        assert_eq!(row.amount, "50");
414        assert_eq!(row.from_commodity, "usd");
415        assert_eq!(row.to_commodity, "jpy");
416        assert_eq!(row.amount_converted.as_deref(), Some("7500"));
417    }
418
419    #[test]
420    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        let draft = TransactionDraft {
425            note: None,
426            date: None,
427            splits: vec![
428                split("checking", "usd", -100, 1),
429                split("food", "usd", 60, 1),
430                split("rent", "usd", 40, 1),
431            ],
432            tags: vec![],
433        };
434        assert!(draft_to_prefilled(draft).is_none());
435    }
436
437    #[test]
438    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        let draft = TransactionDraft {
442            note: None,
443            date: None,
444            splits: vec![split("a", "usd", -50, 1), split("b", "usd", 40, 1)],
445            tags: vec![],
446        };
447        assert!(draft_to_prefilled(draft).is_none());
448    }
449
450    #[test]
451    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        let draft = TransactionDraft {
455            note: None,
456            date: None,
457            splits: vec![split("a", "usd", -1, 3), split("b", "usd", 1, 3)],
458            tags: vec![],
459        };
460        assert!(draft_to_prefilled(draft).is_none());
461    }
462
463    #[test]
464    fn unparseable_date_is_rejected() {
465        let draft = TransactionDraft {
466            note: None,
467            date: Some("someday".into()),
468            splits: vec![split("a", "usd", -1, 1), split("b", "usd", 1, 1)],
469            tags: vec![],
470        };
471        assert!(draft_to_prefilled(draft).is_none());
472    }
473
474    #[test]
475    fn wrong_leg_order_is_rejected() {
476        // Credit before debit isn't the from→to shape the form expects.
477        let draft = TransactionDraft {
478            note: None,
479            date: None,
480            splits: vec![split("a", "usd", 50, 1), split("b", "usd", -50, 1)],
481            tags: vec![],
482        };
483        assert!(draft_to_prefilled(draft).is_none());
484    }
485
486    #[test]
487    fn empty_draft_yields_empty_prefill() {
488        let pre = draft_to_prefilled(TransactionDraft::default()).expect("empty is representable");
489        assert!(pre.rows.is_empty());
490        assert!(pre.note.is_none());
491        assert!(pre.tags.is_empty());
492    }
493
494    #[test]
495    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        let draft = TransactionDraft {
499            note: Some("</script><img src=x onerror=alert(1)>".into()),
500            date: None,
501            splits: vec![],
502            tags: vec![],
503        };
504        let pre = draft_to_prefilled(draft).expect("note-only draft is representable");
505        let json = to_script_safe_json(&pre).unwrap();
506        assert!(
507            !json.contains("</script>"),
508            "raw </script> must not survive: {json}"
509        );
510        assert!(!json.contains('<') && !json.contains('>') && !json.contains('&'));
511        assert!(
512            json.contains("\\u003c"),
513            "< must be \\u003c-escaped: {json}"
514        );
515        // Still valid JSON that round-trips to the original text.
516        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
517        assert_eq!(
518            parsed["note"], "</script><img src=x onerror=alert(1)>",
519            "escaped JSON must parse back to the original note"
520        );
521    }
522}