Functional Weave
Code in Rust

finance.bank-reconcile-match@1.0.0

impl/rust.rs

5,638 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::dates_days_between::days_between;  ← from dates.days-between ^1.0.0 · built alongside by fune
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::money_from_value;  ← from money.amount ^1.0.0 · built alongside by fune

fn reduce(reference: &str) -> Vec<u8> {
    reference
        .bytes()
        .filter(|b| b.is_ascii_alphanumeric())
        .map(|b| b.to_ascii_uppercase())
        .collect()
}

/// Sørensen-Dice on adjacent character pairs, 0 to 10000, rounded down.
fn similarity(a: &str, b: &str) -> i64 {
    let x = reduce(a);
    let y = reduce(b);
    if !x.is_empty() && x == y {
        return 10000;
    }
    let left: Vec<&[u8]> = x.windows(2).collect();
    let right: Vec<&[u8]> = y.windows(2).collect();
    if left.is_empty() || right.is_empty() {
        return 0;
    }
    let mut used = vec![false; right.len()];
    let mut shared: i64 = 0;
    for pair in &left {
        if let Some(at) = (0..right.len()).find(|&i| !used[i] && right[i] == *pair) {
            used[at] = true;
            shared += 1;
        }
    }
    round_div(2 * shared * 10000, (left.len() + right.len()) as i64, "down")
}

fn check_ids(lines: &[ReconcileLine], side: &str) {
    for (i, line) in lines.iter().enumerate() {
        if lines[..i].iter().any(|other| other.id == line.id) {
            panic!("duplicate {} id \"{}\"", side, line.id);
        }
    }
}

/// Pair bank lines with ledger entries: equal amount, dates within the window,
/// references alike enough, best pairs first and ties broken by input order.
///
/// # Panics
/// Panics on a negative window, a similarity outside 0..=10000, duplicate ids
/// or malformed dates.
pub fn match_bank_lines(
    bank_lines: &[ReconcileLine],
    ledger_entries: &[ReconcileLine],
    date_window_days: i64,
    min_similarity: i64,
) -> Reconciliation {
    if date_window_days < 0 {
        panic!("dateWindowDays must be 0 or more, received {}", date_window_days);
    }
    if !(0..=10000).contains(&min_similarity) {
        panic!("minSimilarity must be 0 to 10000, received {}", min_similarity);
    }
    check_ids(bank_lines, "bank line");
    check_ids(ledger_entries, "ledger entry");

    // (score, days, bank index, ledger index)
    let mut candidates: Vec<(i64, i64, usize, usize)> = Vec::new();
    for (b, bank) in bank_lines.iter().enumerate() {
        for (l, entry) in ledger_entries.iter().enumerate() {
            let days = days_between(&entry.date, &bank.date);
            if bank.amount.currency != entry.amount.currency || bank.amount.minor != entry.amount.minor {
                continue;
            }
            if days.abs() > date_window_days {
                continue;
            }
            let score = similarity(&bank.reference, &entry.reference);
            if score < min_similarity {
                continue;
            }
            candidates.push((score, days, b, l));
        }
    }
    candidates.sort_by(|p, q| {
        q.0.cmp(&p.0)
            .then(p.1.abs().cmp(&q.1.abs()))
            .then(p.2.cmp(&q.2))
            .then(p.3.cmp(&q.3))
    });

    let mut bank_match: Vec<Option<ReconcileMatch>> = vec![None; bank_lines.len()];
    let mut ledger_used = vec![false; ledger_entries.len()];
    for (score, days, b, l) in candidates {
        if bank_match[b].is_some() || ledger_used[l] {
            continue;
        }
        bank_match[b] = Some(ReconcileMatch {
            bank_id: bank_lines[b].id.clone(),
            ledger_id: ledger_entries[l].id.clone(),
            day_difference: days,
            similarity: score,
        });
        ledger_used[l] = true;
    }

    let mut matches = Vec::new();
    let mut unmatched_bank = Vec::new();
    for (b, found) in bank_match.into_iter().enumerate() {
        match found {
            Some(m) => matches.push(m),
            None => unmatched_bank.push(bank_lines[b].id.clone()),
        }
    }
    let unmatched_ledger = ledger_entries
        .iter()
        .enumerate()
        .filter(|(l, _)| !ledger_used[*l])
        .map(|(_, e)| e.id.clone())
        .collect();
    Reconciliation {
        matches,
        unmatched_bank,
        unmatched_ledger,
    }
}

pub fn reconcile_line_from_value(v: &Value) -> ReconcileLine {
    ReconcileLine {
        id: v.get("id").as_str().to_string(),
        date: v.get("date").as_str().to_string(),
        amount: money_from_value(v.get("amount")),
        reference: v.get("reference").as_str().to_string(),
    }
}

fn strings(items: &[String]) -> Value {
    Value::Arr(items.iter().map(|s| Value::str(s)).collect())
}

pub fn reconciliation_to_value(r: &Reconciliation) -> Value {
    Value::obj(vec![
        (
            "matches",
            Value::Arr(
                r.matches
                    .iter()
                    .map(|m| {
                        Value::obj(vec![
                            ("bankId", Value::str(&m.bank_id)),
                            ("ledgerId", Value::str(&m.ledger_id)),
                            ("dayDifference", Value::Int(m.day_difference)),
                            ("similarity", Value::Int(m.similarity)),
                        ])
                    })
                    .collect(),
            ),
        ),
        ("unmatchedBank", strings(&r.unmatched_bank)),
        ("unmatchedLedger", strings(&r.unmatched_ledger)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let bank: Vec<ReconcileLine> = args[0].as_arr().iter().map(reconcile_line_from_value).collect();
    let ledger: Vec<ReconcileLine> = args[1].as_arr().iter().map(reconcile_line_from_value).collect();
    reconciliation_to_value(&match_bank_lines(&bank, &ledger, args[2].as_i64(), args[3].as_i64()))
}