Lines
91.55 %
Functions
39.16 %
Branches
100 %
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
use finance::price::Price;
use finance::split::Split;
use nomiscript::{Expr, Fraction, Reader};
use uuid::Uuid;
#[derive(Debug)]
pub(super) struct UpdateTransactionInput {
pub(super) transaction_id: Uuid,
pub(super) post_date: Option<DateTime<Utc>>,
pub(super) enter_date: Option<DateTime<Utc>>,
pub(super) note: Option<String>,
pub(super) splits: Option<Vec<Split>>,
pub(super) prices: Vec<Price>,
}
pub(super) struct CreateTransactionInput {
pub(super) id: Uuid,
pub(super) post_date: DateTime<Utc>,
pub(super) enter_date: DateTime<Utc>,
pub(super) splits: Vec<Split>,
pub(super) fn parse_update_transaction_payload(
src: &str,
) -> Result<UpdateTransactionInput, String> {
let program = Reader::parse(src).map_err(|err| format!("payload parse: {err}"))?;
let first = program
.exprs
.into_iter()
.next()
.ok_or_else(|| "payload empty".to_string())?;
let plist = expect_plist(first, "payload")?;
let tx_raw = take_plist_string(&plist, "transaction-id")?
.ok_or_else(|| "payload: missing :transaction-id".to_string())?;
let transaction_id = Uuid::parse_str(&tx_raw)
.map_err(|err| format!("payload: invalid :transaction-id '{tx_raw}': {err}"))?;
let post_date = match take_plist_string(&plist, "post-date")? {
Some(raw) => Some(
DateTime::parse_from_rfc3339(&raw)
.map(|d| d.with_timezone(&Utc))
.map_err(|err| format!("payload: invalid :post-date '{raw}': {err}"))?,
),
None => None,
};
let enter_date = match take_plist_string(&plist, "enter-date")? {
.map_err(|err| format!("payload: invalid :enter-date '{raw}': {err}"))?,
let note = take_plist_string(&plist, "note")?;
let splits = match take_plist_list(&plist, "splits")? {
Some(list) => {
if list.len() < 2 {
return Err("payload: :splits must have at least two entries".into());
Some(
list.into_iter()
.enumerate()
.map(|(idx, e)| parse_split_plist(e, idx))
.collect::<Result<Vec<_>, _>>()?,
)
let price_default_date = post_date.unwrap_or_else(Utc::now);
let prices = match take_plist_list(&plist, "prices")? {
Some(list) => list
.map(|(idx, expr)| parse_price_plist(expr, idx, price_default_date))
None => Vec::new(),
Ok(UpdateTransactionInput {
transaction_id,
post_date,
enter_date,
note,
splits,
prices,
})
pub(super) fn parse_create_transaction_payload(
) -> Result<CreateTransactionInput, String> {
let post_date_raw = take_plist_string(&plist, "post-date")?
.ok_or_else(|| "payload: missing :post-date".to_string())?;
let post_date = DateTime::parse_from_rfc3339(&post_date_raw)
.map_err(|err| format!("payload: invalid :post-date '{post_date_raw}': {err}"))?;
Some(raw) => DateTime::parse_from_rfc3339(&raw)
None => Utc::now(),
let id = match take_plist_string(&plist, "id")? {
Some(raw) => {
Uuid::parse_str(&raw).map_err(|err| format!("payload: invalid :id '{raw}': {err}"))?
None => Uuid::new_v4(),
let splits_list =
take_plist_list(&plist, "splits")?.ok_or_else(|| "payload: missing :splits".to_string())?;
if splits_list.len() < 2 {
let splits = splits_list
.map(|(idx, expr)| parse_split_plist(expr, idx))
.collect::<Result<Vec<_>, _>>()?;
.map(|(idx, expr)| parse_price_plist(expr, idx, post_date))
Ok(CreateTransactionInput {
id,
pub(super) fn parse_split_plist(expr: Expr, idx: usize) -> Result<Split, String> {
let plist = expect_plist(expr, &format!("split[{idx}]"))?;
Some(raw) => Uuid::parse_str(&raw)
.map_err(|err| format!("split[{idx}]: invalid :id '{raw}': {err}"))?,
let account_raw = take_plist_string(&plist, "account-id")?
.ok_or_else(|| format!("split[{idx}]: missing :account-id"))?;
let account_id = Uuid::parse_str(&account_raw)
.map_err(|err| format!("split[{idx}]: invalid :account-id '{account_raw}': {err}"))?;
let commodity_raw = take_plist_string(&plist, "commodity-id")?
.ok_or_else(|| format!("split[{idx}]: missing :commodity-id"))?;
let commodity_id = Uuid::parse_str(&commodity_raw)
.map_err(|err| format!("split[{idx}]: invalid :commodity-id '{commodity_raw}': {err}"))?;
let value = take_plist_number(&plist, "value")?
.ok_or_else(|| format!("split[{idx}]: missing :value"))?;
Ok(Split {
tx_id: Uuid::nil(),
account_id,
commodity_id,
reconcile_state: None,
reconcile_date: None,
value_num: *value.numer(),
value_denom: *value.denom(),
lot_id: None,
pub(super) fn parse_price_plist(
expr: Expr,
idx: usize,
default_date: DateTime<Utc>,
) -> Result<Price, String> {
let plist = expect_plist(expr, &format!("price[{idx}]"))?;
.map_err(|err| format!("price[{idx}]: invalid :id '{raw}': {err}"))?,
.ok_or_else(|| format!("price[{idx}]: missing :commodity-id"))?;
.map_err(|err| format!("price[{idx}]: invalid :commodity-id '{commodity_raw}': {err}"))?;
let currency_raw = take_plist_string(&plist, "currency-id")?
.ok_or_else(|| format!("price[{idx}]: missing :currency-id"))?;
let currency_id = Uuid::parse_str(¤cy_raw)
.map_err(|err| format!("price[{idx}]: invalid :currency-id '{currency_raw}': {err}"))?;
let commodity_split_raw = take_plist_string(&plist, "commodity-split")?
.ok_or_else(|| format!("price[{idx}]: missing :commodity-split"))?;
let commodity_split = Uuid::parse_str(&commodity_split_raw).map_err(|err| {
format!("price[{idx}]: invalid :commodity-split '{commodity_split_raw}': {err}")
})?;
let currency_split_raw = take_plist_string(&plist, "currency-split")?
.ok_or_else(|| format!("price[{idx}]: missing :currency-split"))?;
let currency_split = Uuid::parse_str(¤cy_split_raw).map_err(|err| {
format!("price[{idx}]: invalid :currency-split '{currency_split_raw}': {err}")
let value_num = take_plist_i64(&plist, "value-num")?
.ok_or_else(|| format!("price[{idx}]: missing :value-num"))?;
let value_denom = take_plist_i64(&plist, "value-denom")?
.ok_or_else(|| format!("price[{idx}]: missing :value-denom"))?;
if value_denom == 0 {
return Err(format!("price[{idx}]: :value-denom must be non-zero"));
let date = match take_plist_string(&plist, "date")? {
.map_err(|err| format!("price[{idx}]: invalid :date '{raw}': {err}"))?,
None => default_date,
Ok(Price {
date,
currency_id,
commodity_split: Some(commodity_split),
currency_split: Some(currency_split),
value_num,
value_denom,
/// Returns the (keyword, value) pairs the plist carries. The outer list
/// is interpreted as a flat plist: every even-index element must be a
/// `Keyword`; the following element is its value.
pub(super) fn expect_plist(expr: Expr, context: &str) -> Result<Vec<(String, Expr)>, String> {
let items = match expr {
Expr::List(items) => items,
other => return Err(format!("{context}: expected plist, got {other:?}")),
if !items.len().is_multiple_of(2) {
return Err(format!(
"{context}: plist has odd number of elements ({})",
items.len()
));
let mut out = Vec::with_capacity(items.len() / 2);
let mut iter = items.into_iter();
while let Some(key) = iter.next() {
let key_name = match key {
Expr::Keyword(name) => name,
other => return Err(format!("{context}: expected :keyword, got {other:?}")),
let value = iter
.ok_or_else(|| format!("{context}: dangling :{key_name} without value"))?;
out.push((key_name, value));
Ok(out)
pub(super) fn take_plist_string(
plist: &[(String, Expr)],
key: &str,
) -> Result<Option<String>, String> {
let upper = key.to_ascii_uppercase();
match plist.iter().find(|(k, _)| k.eq_ignore_ascii_case(&upper)) {
Some((_, Expr::String(s))) => Ok(Some(s.clone())),
Some((_, Expr::Nil)) => Ok(None),
Some((_, other)) => Err(format!(":{key} must be string, got {other:?}")),
None => Ok(None),
pub(super) fn take_plist_number(
) -> Result<Option<Fraction>, String> {
Some((_, Expr::Number(n))) => Ok(Some(*n)),
Some((_, other)) => Err(format!(":{key} must be number, got {other:?}")),
pub(super) fn take_plist_list(
) -> Result<Option<Vec<Expr>>, String> {
Some((_, Expr::List(items))) => Ok(Some(items.clone())),
Some((_, other)) => Err(format!(":{key} must be list, got {other:?}")),
pub(super) fn take_plist_i64(plist: &[(String, Expr)], key: &str) -> Result<Option<i64>, String> {
Some((_, Expr::Number(n))) => {
if *n.denom() == 1 {
Ok(Some(*n.numer()))
} else {
Err(format!(":{key} must be integer, got {n}"))
Some((_, other)) => Err(format!(":{key} must be integer, got {other:?}")),
pub(super) fn parse_transaction_id_arg(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
wasmtime::Error::msg("transaction id: missing or empty :transaction-id arg")
Uuid::parse_str(&raw)
.map_err(|err| wasmtime::Error::msg(format!("transaction id: invalid uuid '{raw}': {err}")))
pub(super) fn parse_account_filter_arg(
account_arg: Option<String>,
) -> wasmtime::Result<Option<Uuid>> {
match account_arg.filter(|s| !s.is_empty()) {
Some(s) => Uuid::parse_str(&s).map(Some).map_err(|err| {
wasmtime::Error::msg(format!(
"list-transactions: invalid account uuid '{s}': {err}"
))
}),
/// One logical (from→to) split in the logical transaction payload.
pub(super) struct LogicalSplitInput {
pub(super) from: String,
pub(super) to: String,
pub(super) from_commodity: String,
pub(super) to_commodity: String,
pub(super) value: Fraction,
pub(super) to_amount: Option<Fraction>,
/// Parsed form of the `create-transaction-logical` payload.
pub(super) struct LogicalTransactionInput {
pub(super) splits: Vec<LogicalSplitInput>,
/// Parsed form of the `update-transaction-logical` payload.
pub(super) struct LogicalTransactionUpdateInput {
/// Parse a `:date` value tolerantly: RFC3339 (with tz), `YYYY-MM-DDTHH:MM[:SS]`
/// (no tz, treated as UTC), or bare `YYYY-MM-DD` (midnight UTC). The F3 form and
/// templates emit the latter two; rejecting them would break the form path.
fn parse_flexible_date(raw: &str) -> Result<DateTime<Utc>, String> {
if let Ok(dt) = DateTime::parse_from_rfc3339(raw) {
return Ok(dt.with_timezone(&Utc));
for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"] {
if let Ok(naive) = NaiveDateTime::parse_from_str(raw, fmt) {
return Ok(naive.and_utc());
NaiveDate::parse_from_str(raw, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|naive| naive.and_utc())
.ok_or_else(|| format!("payload: invalid :date '{raw}'"))
pub(super) fn parse_create_transaction_logical_payload(
) -> Result<LogicalTransactionInput, String> {
let post_date = match take_plist_string(&plist, "date")? {
Some(raw) => Some(parse_flexible_date(&raw)?),
if splits_list.is_empty() {
return Err("payload: :splits must be non-empty".into());
.map(|(idx, e)| parse_logical_split_plist(e, idx))
Ok(LogicalTransactionInput {
pub(super) fn parse_update_transaction_logical_payload(
) -> Result<LogicalTransactionUpdateInput, String> {
Ok(LogicalTransactionUpdateInput {
fn parse_logical_split_plist(expr: Expr, idx: usize) -> Result<LogicalSplitInput, String> {
let from =
take_plist_string(&plist, "from")?.ok_or_else(|| format!("split[{idx}]: missing :from"))?;
let to =
take_plist_string(&plist, "to")?.ok_or_else(|| format!("split[{idx}]: missing :to"))?;
let from_commodity = take_plist_string(&plist, "from-commodity")?
.ok_or_else(|| format!("split[{idx}]: missing :from-commodity"))?;
let to_commodity = take_plist_string(&plist, "to-commodity")?
.ok_or_else(|| format!("split[{idx}]: missing :to-commodity"))?;
let to_amount = take_plist_number(&plist, "to-amount")?;
Ok(LogicalSplitInput {
from,
to,
from_commodity,
to_commodity,
value,
to_amount,