Lines
99.68 %
Functions
62.92 %
Branches
100 %
//! Maps a rendered [`rpc::TransactionDraft`] (a flat list of signed splits)
//! onto the create form's transfer-row model (each row is a from→to pair that
//! `process_split_data` later expands back into two splits).
//!
//! **Lossless-or-nothing.** The form's transfer-row model is strictly LESS
//! expressive than a flat split list, so a naive sign-pairing would invent
//! structure that submitting can't reconstruct — silently persisting a
//! different transaction than the template rendered. To never do that,
//! [`draft_to_prefilled`] only accepts drafts it can represent EXACTLY and
//! returns `None` otherwise (the create page then simply shows no prefill). The
//! accepted shape matches how favourite-transaction templates are written:
//! consecutive `(from −x)` then `(to +x)` `draft-split` pairs, each amount an
//! exact terminating decimal, and same-commodity pairs balancing to equal
//! magnitude. Ambiguous 1→N drafts, non-terminating ratios (`1/3`), and
//! unparseable dates all fail closed rather than degrade.
use serde::Serialize;
/// One transfer row for the create form. Account/commodity are uuids; the
/// browser resolves display names via `/api/account/list` (as the
/// from-account prefill already does), so the server side stays name-free.
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct PrefilledRow {
pub amount: String,
pub from_account: String,
pub from_commodity: String,
pub to_account: String,
pub to_commodity: String,
/// Set only when the two legs carry different commodities (a conversion).
pub amount_converted: Option<String>,
/// Tags the template attached to the debit (from) leg of this row.
pub from_tags: Vec<PrefilledTag>,
/// Tags the template attached to the credit (to) leg of this row.
pub to_tags: Vec<PrefilledTag>,
}
pub struct PrefilledTag {
pub name: String,
pub value: String,
#[derive(Debug, Serialize, PartialEq, Eq, Default)]
pub struct PrefilledDraft {
pub note: Option<String>,
pub date: Option<String>,
pub rows: Vec<PrefilledRow>,
pub tags: Vec<PrefilledTag>,
/// Most fractional digits the create form's submit path can round-trip. It
/// rebuilds the rational as `10_i64.pow(decimals)`, which overflows `i64` past
/// ~18 digits, so cap below that with margin. A terminating decimal needing
/// more places than this is rejected rather than risk an overflowing/lossy
/// round-trip on submit.
const MAX_FRACTIONAL_DIGITS: usize = 15;
/// Formats `|num/denom|` as an EXACT decimal string the create form accepts
/// (it parses integers and dotted decimals, then back to an exact rational for
/// storage). Returns `None` when the value is not a terminating decimal (the
/// reduced denominator has a prime factor other than 2 or 5, e.g. `1/3`), needs
/// more than [`MAX_FRACTIONAL_DIGITS`] places, or would overflow during digit
/// expansion — so a non-round-trippable amount fails the prefill instead of
/// being silently rounded/corrupted before persistence.
fn exact_decimal(num: i64, denom: i64) -> Option<String> {
if denom == 0 {
return None;
let mut n = num.unsigned_abs();
let mut d = denom.unsigned_abs();
let g = gcd(n, d);
n /= g;
d /= g;
// Strip all 2s and 5s from the reduced denominator; whatever remains makes
// the decimal non-terminating.
let mut reduced = d;
while reduced.is_multiple_of(2) {
reduced /= 2;
while reduced.is_multiple_of(5) {
reduced /= 5;
if reduced != 1 {
let int_part = n / d;
let mut rem = n % d;
if rem == 0 {
let s = int_part.to_string();
return submit_round_trips(&s).then_some(s);
let mut frac = String::new();
while rem != 0 {
if frac.len() >= MAX_FRACTIONAL_DIGITS {
// Checked: a huge reduced denominator could overflow `rem * 10`.
rem = rem.checked_mul(10)?;
frac.push(char::from(b'0' + (rem / d) as u8));
rem %= d;
let s = format!("{int_part}.{frac}");
submit_round_trips(&s).then_some(s)
/// Whether the create form's submit parser (`parse_amount_to_rational`) can
/// rebuild this decimal string WITHOUT overflow: it strips the dot and parses
/// the whole thing as an `i64` numerator over `10^decimals`. A large integer
/// part can push the dot-stripped numerator past `i64::MAX` even within the
/// scale cap, so verify both fit before offering the value as prefill — keeping
/// the "lossless-or-nothing" guarantee through to persistence.
fn submit_round_trips(decimal: &str) -> bool {
let digits: String = decimal.chars().filter(|c| *c != '.').collect();
if digits.parse::<i64>().is_err() {
return false;
if let Some(dot) = decimal.find('.') {
let decimals = (decimal.len() - dot - 1) as u32;
if 10_i64.checked_pow(decimals).is_none() {
true
fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
(a, b) = (b, a % b);
a.max(1)
/// Normalizes a template-supplied date to the `datetime-local` form
/// (`YYYY-MM-DDTHH:MM`) the create page expects. Accepts a bare date
/// (`YYYY-MM-DD` → midnight), a `datetime-local` value, or RFC3339. Returns
/// `None` for anything else so a malformed template date fails the prefill
/// rather than silently submitting as "now".
///
/// An RFC3339 value with an offset is reduced to its UTC instant (`naive_utc`)
/// — NOT local time — because the submit path stamps the resubmitted naive
/// datetime with `.and_utc()`. Using UTC here keeps the persisted instant equal
/// to the template's, instead of shifting it by the server's timezone.
fn normalize_date(raw: &str) -> Option<String> {
use chrono::{NaiveDate, NaiveDateTime};
let raw = raw.trim();
if let Ok(d) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
return Some(d.format("%Y-%m-%dT00:00").to_string());
for fmt in ["%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"] {
if let Ok(dt) = NaiveDateTime::parse_from_str(raw, fmt) {
return Some(dt.format("%Y-%m-%dT%H:%M").to_string());
chrono::DateTime::parse_from_rfc3339(raw)
.ok()
.map(|dt| dt.naive_utc().format("%Y-%m-%dT%H:%M").to_string())
/// Serializes a value to JSON safe for inlining inside an HTML `<script>`
/// block. `serde_json` does not escape `<`, `>`, or `&`, so a string field
/// containing `</script>` would otherwise break out of the tag (a template
/// author controls the note/tag text → stored self-XSS). Escaping `<`/`>`/`&`
/// to their `\uXXXX` forms is still valid JSON that `JSON.parse` /
/// `window.x = …` reads identically, and cannot terminate the script element.
pub fn to_script_safe_json<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
let json = serde_json::to_string(value)?;
Ok(json
.replace('<', "\\u003c")
.replace('>', "\\u003e")
.replace('&', "\\u0026"))
/// Pairs consecutive `(from, to)` draft splits into one exactly-representable
/// transfer row, or `None` if the pair can't be losslessly shown as a form row.
fn pair_to_row(from: &rpc::DraftSplit, to: &rpc::DraftSplit) -> Option<PrefilledRow> {
// First leg must be the debit (money out), second the credit (money in) —
// the order favourite-transaction templates write them.
if from.value_num >= 0 || to.value_num < 0 {
let from_amount = exact_decimal(from.value_num, from.value_denom)?;
let to_amount = exact_decimal(to.value_num, to.value_denom)?;
let amount_converted = if from.commodity_id == to.commodity_id {
// Same-commodity transfer: the form forces the credit to equal the
// debit, so a draft whose legs disagree can't be represented here.
if from_amount != to_amount {
None
} else {
Some(to_amount)
};
Some(PrefilledRow {
amount: from_amount,
from_account: from.account_id.clone(),
from_commodity: from.commodity_id.clone(),
to_account: to.account_id.clone(),
to_commodity: to.commodity_id.clone(),
amount_converted,
from_tags: convert_tags(&from.tags),
to_tags: convert_tags(&to.tags),
})
/// Builds a prefill for the create form, or `None` when the draft cannot be
/// represented EXACTLY as transfer rows (ambiguous split structure,
/// non-terminating amount, or unparseable date). Returning `None` means "show
/// no prefill" — never a silently-altered transaction.
#[must_use]
pub fn draft_to_prefilled(draft: rpc::TransactionDraft) -> Option<PrefilledDraft> {
let rpc::TransactionDraft {
note,
date,
splits,
tags,
} = draft;
// Splits must form consecutive debit→credit pairs.
if splits.len() % 2 != 0 {
let mut rows = Vec::with_capacity(splits.len() / 2);
for pair in splits.chunks_exact(2) {
rows.push(pair_to_row(&pair[0], &pair[1])?);
let date = match date {
Some(raw) => Some(normalize_date(&raw)?),
None => None,
Some(PrefilledDraft {
rows,
tags: convert_tags(&tags),
/// Maps render-layer draft tags to the prefill's serializable tag shape.
fn convert_tags(tags: &[rpc::DraftTag]) -> Vec<PrefilledTag> {
tags.iter()
.map(|t| PrefilledTag {
name: t.name.clone(),
value: t.value.clone(),
.collect()
#[cfg(test)]
mod tests {
use super::*;
use rpc::{DraftSplit, DraftTag, TransactionDraft};
fn split(account: &str, commodity: &str, num: i64, denom: i64) -> DraftSplit {
DraftSplit {
account_id: account.to_string(),
commodity_id: commodity.to_string(),
value_num: num,
value_denom: denom,
tags: vec![],
fn tag(name: &str, value: &str) -> DraftTag {
DraftTag {
name: name.to_string(),
value: value.to_string(),
#[test]
fn exact_decimal_terminating_only() {
assert_eq!(exact_decimal(-50, 1).as_deref(), Some("50"));
assert_eq!(exact_decimal(505, 100).as_deref(), Some("5.05"));
assert_eq!(exact_decimal(-1, 2).as_deref(), Some("0.5"));
assert_eq!(exact_decimal(100, 1).as_deref(), Some("100"));
assert_eq!(exact_decimal(1, 8).as_deref(), Some("0.125"));
// Non-terminating: must fail closed.
assert_eq!(exact_decimal(1, 3), None);
assert_eq!(exact_decimal(2, 7), None);
assert_eq!(exact_decimal(1, 0), None);
fn exact_decimal_rejects_unrepresentable_scale() {
// 1 / 2^60 is a terminating decimal in theory but needs 60 fractional
// digits — past what the form's submit path can round-trip — so it must
// fail closed rather than emit a value that overflows on reparse.
let huge_denom = 1_i64 << 60;
assert_eq!(exact_decimal(1, huge_denom), None);
// 1/1024 = 0.0009765625 is exactly 10 digits — within bounds.
assert_eq!(exact_decimal(1, 1024).as_deref(), Some("0.0009765625"));
fn exact_decimal_rejects_submit_numerator_overflow() {
// i64::MAX / 2 is `…3.5` — only one fractional digit (within the scale
// cap), but the submit parser strips the dot and parses a 20-digit
// numerator, which overflows i64. Must fail closed, not render-then-fail.
assert_eq!(exact_decimal(i64::MAX, 2), None);
// The submit-side round-trip really would reject it (sanity on the
// parser used by the form).
let dot_stripped = format!("{}5", i64::MAX / 2);
assert!(dot_stripped.parse::<i64>().is_err());
fn rfc3339_date_normalizes_to_utc_instant() {
// A +02:00 offset must reduce to the UTC instant (07:30), because the
// submit path re-stamps the naive value as UTC — using local time would
// shift the persisted instant by the server timezone.
assert_eq!(
normalize_date("2026-06-15T09:30:00+02:00").as_deref(),
Some("2026-06-15T07:30")
);
// A plain datetime-local is preserved verbatim (already wall-clock).
normalize_date("2026-06-15T09:30").as_deref(),
Some("2026-06-15T09:30")
fn normalize_date_accepts_known_shapes_else_none() {
normalize_date("2026-06-15").as_deref(),
Some("2026-06-15T00:00")
normalize_date("2026-06-15T09:30:45").as_deref(),
assert!(normalize_date("yesterday").is_none());
assert!(normalize_date("06/15/2026").is_none());
fn balanced_two_split_draft_yields_one_row() {
let draft = TransactionDraft {
note: Some("Groceries".into()),
date: Some("2026-06-15".into()),
splits: vec![
split("checking", "usd", -50, 1),
split("food", "usd", 50, 1),
],
tags: vec![DraftTag {
name: "category".into(),
value: "food".into(),
}],
let pre = draft_to_prefilled(draft).expect("balanced transfer is representable");
assert_eq!(pre.note.as_deref(), Some("Groceries"));
assert_eq!(pre.date.as_deref(), Some("2026-06-15T00:00"));
assert_eq!(pre.rows.len(), 1);
let row = &pre.rows[0];
assert_eq!(row.from_account, "checking");
assert_eq!(row.to_account, "food");
assert_eq!(row.amount, "50");
assert_eq!(row.amount_converted, None);
assert_eq!(pre.tags.len(), 1);
assert_eq!(pre.tags[0].name, "category");
fn split_tags_map_to_legs_by_sign() {
// A tag on the debit (negative) split lands on the row's from-leg; a tag
// on the credit (positive) split lands on the to-leg.
let mut from = split("checking", "usd", -50, 1);
from.tags.push(tag("memo", "rent"));
let mut to = split("housing", "usd", 50, 1);
to.tags.push(tag("class", "fixed"));
note: None,
date: None,
splits: vec![from, to],
let pre = draft_to_prefilled(draft).expect("representable");
assert_eq!(row.from_tags.len(), 1);
assert_eq!(row.from_tags[0].name, "memo");
assert_eq!(row.from_tags[0].value, "rent");
assert_eq!(row.to_tags.len(), 1);
assert_eq!(row.to_tags[0].name, "class");
assert_eq!(row.to_tags[0].value, "fixed");
fn cross_commodity_pair_sets_amount_converted() {
split("wallet", "usd", -50, 1),
split("shop", "jpy", 7500, 1),
let pre = draft_to_prefilled(draft).expect("conversion transfer is representable");
assert_eq!(row.from_commodity, "usd");
assert_eq!(row.to_commodity, "jpy");
assert_eq!(row.amount_converted.as_deref(), Some("7500"));
fn ambiguous_one_to_n_draft_is_rejected() {
// One debit, two credits — not losslessly representable as transfer
// rows (the 3rd split would have no debit partner), so fail closed
// rather than invent structure that submits a different transaction.
split("checking", "usd", -100, 1),
split("food", "usd", 60, 1),
split("rent", "usd", 40, 1),
assert!(draft_to_prefilled(draft).is_none());
fn same_commodity_unbalanced_pair_is_rejected() {
// A same-commodity row's credit is forced to equal its debit by the
// form, so a pair whose legs disagree can't be shown losslessly.
splits: vec![split("a", "usd", -50, 1), split("b", "usd", 40, 1)],
fn non_terminating_amount_is_rejected() {
// 1/3 USD can't be an exact decimal the form round-trips, so reject
// rather than persist a truncated 0.333333.
splits: vec![split("a", "usd", -1, 3), split("b", "usd", 1, 3)],
fn unparseable_date_is_rejected() {
date: Some("someday".into()),
splits: vec![split("a", "usd", -1, 1), split("b", "usd", 1, 1)],
fn wrong_leg_order_is_rejected() {
// Credit before debit isn't the from→to shape the form expects.
splits: vec![split("a", "usd", 50, 1), split("b", "usd", -50, 1)],
fn empty_draft_yields_empty_prefill() {
let pre = draft_to_prefilled(TransactionDraft::default()).expect("empty is representable");
assert!(pre.rows.is_empty());
assert!(pre.note.is_none());
assert!(pre.tags.is_empty());
fn script_safe_json_escapes_script_breakout() {
// A template author who sets a note containing `</script>` must not be
// able to break out of the inline <script> the create page emits.
note: Some("</script><img src=x onerror=alert(1)>".into()),
splits: vec![],
let pre = draft_to_prefilled(draft).expect("note-only draft is representable");
let json = to_script_safe_json(&pre).unwrap();
assert!(
!json.contains("</script>"),
"raw </script> must not survive: {json}"
assert!(!json.contains('<') && !json.contains('>') && !json.contains('&'));
json.contains("\\u003c"),
"< must be \\u003c-escaped: {json}"
// Still valid JSON that round-trips to the original text.
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
parsed["note"], "</script><img src=x onerror=alert(1)>",
"escaped JSON must parse back to the original note"