Functional Weave
Code in Rust

finance.ledger.journal-validate@1.0.0

impl/rust.rs

5,040 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::money_add::add_money;  ← from money.add ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value};  ← from money.amount ^1.0.0 · built alongside by fune

/// 1-20 ASCII letters and digits, in groups joined by a single - . or /
fn is_account_code(account: &str) -> bool {
    let bytes = account.as_bytes();
    if bytes.is_empty() || bytes.len() > 20 {
        return false;
    }
    let mut previous_was_alnum = false;
    for &b in bytes {
        if b.is_ascii_alphanumeric() {
            previous_was_alnum = true;
        } else if (b == b'-' || b == b'.' || b == b'/') && previous_was_alnum {
            previous_was_alnum = false;
        } else {
            return false;
        }
    }
    previous_was_alnum
}

/// Check a journal before it is posted, reporting every problem at once.
///
/// A line in the wrong currency or with a negative amount is left out of the
/// totals, since adding it would make them meaningless; the other problems
/// leave the line's amounts in.
///
/// # Panics
/// Panics only if `currency` is not an ISO 4217 code.
pub fn validate_journal(lines: &[JournalLine], currency: &str) -> JournalCheck {
    let mut debit_total = money(0, currency);
    let mut credit_total = money(0, currency);
    let mut problems: Vec<JournalProblem> = Vec::new();
    let mut report = |line: Option<i64>, code: &str, message: String| {
        problems.push(JournalProblem {
            line,
            code: code.to_string(),
            message,
        });
    };

    if lines.is_empty() {
        report(None, "empty", "the journal has no lines".to_string());
    }
    for (index, entry) in lines.iter().enumerate() {
        let n = index as i64 + 1;
        if !is_account_code(&entry.account) {
            report(
                Some(n),
                "invalid-account",
                format!("line {}: account \"{}\" is not a valid account code", n, entry.account),
            );
        }
        let foreign = if entry.debit.currency != currency {
            Some(entry.debit.currency.clone())
        } else if entry.credit.currency != currency {
            Some(entry.credit.currency.clone())
        } else {
            None
        };
        if let Some(foreign) = foreign {
            report(
                Some(n),
                "currency-mismatch",
                format!("line {}: amount is in {}, not the journal currency {}", n, foreign, currency),
            );
            continue;
        }
        if entry.debit.minor < 0 || entry.credit.minor < 0 {
            report(
                Some(n),
                "negative-amount",
                format!("line {}: debit and credit must not be negative", n),
            );
            continue;
        }
        if entry.debit.minor == 0 && entry.credit.minor == 0 {
            report(Some(n), "zero-line", format!("line {}: debit and credit are both zero", n));
        } else if entry.debit.minor != 0 && entry.credit.minor != 0 {
            report(Some(n), "both-sides", format!("line {}: has both a debit and a credit", n));
        }
        debit_total = add_money(&debit_total, &entry.debit);
        credit_total = add_money(&credit_total, &entry.credit);
    }

    let difference = money(debit_total.minor - credit_total.minor, currency);
    if difference.minor != 0 {
        report(None, "unbalanced", "debits do not equal credits".to_string());
    }
    JournalCheck {
        valid: problems.is_empty(),
        debit_total,
        credit_total,
        difference,
        problems,
    }
}

pub fn journal_line_from_value(v: &Value) -> JournalLine {
    JournalLine {
        account: v.get("account").as_str().to_string(),
        debit: money_from_value(v.get("debit")),
        credit: money_from_value(v.get("credit")),
    }
}

pub fn journal_check_to_value(check: &JournalCheck) -> Value {
    Value::obj(vec![
        ("valid", Value::Bool(check.valid)),
        ("debitTotal", money_to_value(&check.debit_total)),
        ("creditTotal", money_to_value(&check.credit_total)),
        ("difference", money_to_value(&check.difference)),
        (
            "problems",
            Value::Arr(
                check
                    .problems
                    .iter()
                    .map(|p| {
                        Value::obj(vec![
                            (
                                "line",
                                match p.line {
                                    Some(n) => Value::Int(n),
                                    None => Value::Null,
                                },
                            ),
                            ("code", Value::str(&p.code)),
                            ("message", Value::str(&p.message)),
                        ])
                    })
                    .collect(),
            ),
        ),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let lines: Vec<JournalLine> = args[0].as_arr().iter().map(journal_line_from_value).collect();
    journal_check_to_value(&validate_journal(&lines, args[1].as_str()))
}