use super::funejson::Value; use super::money_add::add_money; use super::money_amount::{money, money_from_value, money_to_value}; /// 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 = Vec::new(); let mut report = |line: Option, 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 = args[0].as_arr().iter().map(journal_line_from_value).collect(); journal_check_to_value(&validate_journal(&lines, args[1].as_str())) }