Lines
100 %
Functions
92.31 %
Branches
//! Pure payload builders for transaction create and update nomiscript forms.
//!
//! The plist payload shape:
//! `(:splits ((:from "uuid" :to "uuid" :from-commodity "uuid"
//! :to-commodity "uuid" :value <ratio> :to-amount <ratio>?) ...)
//! :note "..."? :date "..."?)`
use crate::eval::escape_str;
use crate::forms::validators::{LogicalSplitInput, amount_token, parse_amount};
fn format_ratio(s: &str) -> Result<String, String> {
parse_amount(s).map(|r| amount_token(&r))
}
fn format_split(row: &LogicalSplitInput) -> Result<String, String> {
let value_str = format_ratio(&row.amount)?;
let to_amount_part = if row.from_commodity != row.to_commodity {
let ta = row
.to_amount
.as_deref()
.ok_or_else(|| "to_amount required for cross-commodity split".to_string())?;
format!(" :to-amount {}", format_ratio(ta)?)
} else {
String::new()
};
Ok(format!(
"(:from \"{from}\" :to \"{to}\" :from-commodity \"{fc}\" :to-commodity \"{tc}\" :value {value}{ta})",
from = row.from,
to = row.to,
fc = row.from_commodity,
tc = row.to_commodity,
value = value_str,
ta = to_amount_part,
))
fn build_note_date_parts(note: &str, date: &str) -> (String, String) {
let note_part = if note.is_empty() {
format!(" :note {}", escape_str(note))
let date_part = if date.is_empty() {
format!(" :date {}", escape_str(date))
(note_part, date_part)
fn build_splits_plist(splits: &[LogicalSplitInput]) -> Result<String, String> {
splits
.iter()
.map(format_split)
.collect::<Result<Vec<_>, _>>()
.map(|v| v.join(" "))
/// Build the `(create-transaction-logical "...")` nomiscript form.
///
/// Returns `Err` if any amount fails to parse or a cross-commodity row is
/// missing `to_amount`.
pub fn build_transaction_logical_payload(
splits: &[LogicalSplitInput],
note: &str,
date: &str,
) -> Result<String, String> {
let splits_part = build_splits_plist(splits)?;
let (note_part, date_part) = build_note_date_parts(note, date);
let payload = format!("(:splits ({splits_part}){note_part}{date_part})");
"(create-transaction-logical {})",
escape_str(&payload)
/// Build the `(update-transaction-logical "...")` nomiscript form.
pub fn build_transaction_update_payload(
id: &str,
let payload =
format!("(:transaction-id \"{id}\" :splits ({splits_part}){note_part}{date_part})");
"(update-transaction-logical {})",
#[cfg(test)]
mod tests {
use super::*;
use crate::forms::validators::LogicalSplitInput;
fn row_same(from: &str, to: &str, comm: &str, value: &str) -> LogicalSplitInput {
LogicalSplitInput {
from: from.to_string(),
to: to.to_string(),
from_commodity: comm.to_string(),
to_commodity: comm.to_string(),
amount: value.to_string(),
to_amount: None,
fn row_cross(
from: &str,
to: &str,
fc: &str,
tc: &str,
value: &str,
to_amount: &str,
) -> LogicalSplitInput {
from_commodity: fc.to_string(),
to_commodity: tc.to_string(),
to_amount: Some(to_amount.to_string()),
const FROM: &str = "aaaa0000-0000-0000-0000-000000000001";
const TO: &str = "aaaa0000-0000-0000-0000-000000000002";
const COMM: &str = "cccc0000-0000-0000-0000-000000000001";
const COMM2: &str = "cccc0000-0000-0000-0000-000000000002";
#[test]
fn single_currency_integer_value() {
let splits = vec![row_same(FROM, TO, COMM, "50")];
let result = build_transaction_logical_payload(&splits, "", "").unwrap();
assert!(
result.starts_with("(create-transaction-logical "),
"form prefix: {result}"
);
assert!(result.contains(":value 50"), "integer ratio: {result}");
!result.contains(":to-amount"),
"no to-amount same commodity: {result}"
fn cross_currency_decimal_value() {
let splits = vec![row_cross(FROM, TO, COMM, COMM2, "153.81", "15000")];
result.contains(":value 15381/100"),
"decimal to ratio: {result}"
result.contains(":to-amount 15000"),
"to-amount present: {result}"
fn note_and_date_included() {
let splits = vec![row_same(FROM, TO, COMM, "10")];
let result = build_transaction_logical_payload(&splits, "Groceries", "2024-01-15").unwrap();
assert!(result.contains(":note"), "note present: {result}");
assert!(result.contains(":date"), "date present: {result}");
assert!(result.contains("Groceries"), "note text: {result}");
fn fraction_amount_emitted_as_ratio() {
let splits = vec![row_same(FROM, TO, COMM, "1/3")];
result.contains(":value 1/3"),
"fraction preserved: {result}"
fn cross_commodity_missing_to_amount_is_error() {
let row = LogicalSplitInput {
from: FROM.to_string(),
to: TO.to_string(),
from_commodity: COMM.to_string(),
to_commodity: COMM2.to_string(),
amount: "10".to_string(),
build_transaction_logical_payload(&[row], "", "").is_err(),
"cross-commodity without to_amount must fail"
fn empty_note_and_date_omitted() {
let splits = vec![row_same(FROM, TO, COMM, "5")];
assert!(!result.contains(":note"), "no note when empty: {result}");
assert!(!result.contains(":date"), "no date when empty: {result}");