finance.bank-reconcile-match@1.0.0
impl/python.py
3,722 bytes · the Python 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.
from typing import List, Optional, Sequence
from .dates_days_between import days_between ← from dates.days-between ^1.0.0 · built alongside by fune
from .finance_bank_reconcile_match_types import ReconcileLine, ReconcileMatch, Reconciliation
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
def _reduce(reference: str) -> str:
out = []
for ch in reference:
if "0" <= ch <= "9" or "A" <= ch <= "Z":
out.append(ch)
elif "a" <= ch <= "z":
out.append(chr(ord(ch) - 32))
return "".join(out)
def _bigrams(text: str) -> List[str]:
return [text[i : i + 2] for i in range(len(text) - 1)]
def _similarity(a: str, b: str) -> int:
"""Sørensen-Dice on adjacent character pairs, 0 to 10000, rounded down."""
x = _reduce(a)
y = _reduce(b)
if len(x) > 0 and x == y:
return 10000
left = _bigrams(x)
right = _bigrams(y)
if len(left) == 0 or len(right) == 0:
return 0
used = [False] * len(right)
shared = 0
for pair in left:
for i, other in enumerate(right):
if not used[i] and other == pair:
used[i] = True
shared += 1
break
return round_div(2 * shared * 10000, len(left) + len(right), "down")
def _check_ids(lines: Sequence[ReconcileLine], side: str) -> None:
seen = set()
for line in lines:
if line.id in seen:
raise ValueError('duplicate %s id "%s"' % (side, line.id))
seen.add(line.id)
def match_bank_lines(
bank_lines: Sequence[ReconcileLine],
ledger_entries: Sequence[ReconcileLine],
date_window_days: int,
min_similarity: int,
) -> Reconciliation:
"""Pair bank lines with ledger entries: equal amount, dates within the
window, references alike enough, best pairs first and ties broken by input
order.
"""
if isinstance(date_window_days, bool) or not isinstance(date_window_days, int) or date_window_days < 0:
raise ValueError("dateWindowDays must be 0 or more, received %s" % (date_window_days,))
if (
isinstance(min_similarity, bool)
or not isinstance(min_similarity, int)
or min_similarity < 0
or min_similarity > 10000
):
raise ValueError("minSimilarity must be 0 to 10000, received %s" % (min_similarity,))
_check_ids(bank_lines, "bank line")
_check_ids(ledger_entries, "ledger entry")
candidates = []
for b, bank in enumerate(bank_lines):
for l, entry in enumerate(ledger_entries):
days = days_between(entry.date, bank.date)
if bank.amount.currency != entry.amount.currency or bank.amount.minor != entry.amount.minor:
continue
if abs(days) > date_window_days:
continue
score = _similarity(bank.reference, entry.reference)
if score < min_similarity:
continue
candidates.append((-score, abs(days), b, l, days, score))
candidates.sort()
bank_match: List[Optional[ReconcileMatch]] = [None] * len(bank_lines)
ledger_used = [False] * len(ledger_entries)
for _, _, b, l, days, score in candidates:
if bank_match[b] is not None or ledger_used[l]:
continue
bank_match[b] = ReconcileMatch(
bank_id=bank_lines[b].id, ledger_id=ledger_entries[l].id, day_difference=days, similarity=score
)
ledger_used[l] = True
matches = [m for m in bank_match if m is not None]
unmatched_bank = [bank.id for b, bank in enumerate(bank_lines) if bank_match[b] is None]
unmatched_ledger = [entry.id for l, entry in enumerate(ledger_entries) if not ledger_used[l]]
return Reconciliation(matches=matches, unmatched_bank=unmatched_bank, unmatched_ledger=unmatched_ledger)