1use serde::Serialize;
18
19#[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 pub amount_converted: Option<String>,
31 pub from_tags: Vec<PrefilledTag>,
33 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
51const MAX_FRACTIONAL_DIGITS: usize = 15;
57
58fn 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 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 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
106fn 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
133fn 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
159pub 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
173fn pair_to_row(from: &rpc::DraftSplit, to: &rpc::DraftSplit) -> Option<PrefilledRow> {
176 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 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#[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 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
242fn 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 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 let huge_denom = 1_i64 << 60;
293 assert_eq!(exact_decimal(1, huge_denom), None);
294 assert_eq!(exact_decimal(1, 1024).as_deref(), Some("0.0009765625"));
296 }
297
298 #[test]
299 fn exact_decimal_rejects_submit_numerator_overflow() {
300 assert_eq!(exact_decimal(i64::MAX, 2), None);
304 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 assert_eq!(
316 normalize_date("2026-06-15T09:30:00+02:00").as_deref(),
317 Some("2026-06-15T07:30")
318 );
319 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 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 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 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 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 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 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 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}