Changeset - a4bba120eb37
[Not reviewed]
0 1 0
Ben Sturmfels (bsturmfels) - 19 months ago 2023-02-11 03:53:30
ben@sturm.com.au
reconlicer: Move match thresholds to top of module
1 file changed with 9 insertions and 6 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reconcile/statement_reconciler.py
Show inline comments
...
 
@@ -34,167 +34,170 @@ tool.
 

	
 
That said, this tool *is* still somewhat like an importer in that it needs to
 
extract transaction details from a third-party statement. Instead of creating
 
directives, it just checks to see that similar directives are already
 
present. This is a bit like diff-ing a statement with the books (though we're
 
only interested in the presence of lines, not so much their order).
 

	
 
Paper checks are entered into the books when written (a.k.a. "posted"), but may
 
not be cashed until months later sometimes causing reconciliation differences
 
that live beyond a month. It's worth noting that there are really two dates here
 
- the posting date and the cleared date. Beancount only allows us to model one,
 
which is why carrying these reconciliation differences between months feels a
 
bit awkward.
 

	
 
Problems in scope:
 

	
 
 - errors in the books take hours to find during reconciliation, requiring
 
   manually comparing statements and the books and are succeptible to mistakes,
 
   such as not noticing when there are two payments for the same amount on the
 
   statement, but not in the books (as Bradley likes to quote, "you're entering
 
   a world of pain")
 

	
 
 - adding statement/reconciliation metadata to books is/was manual and prone to
 
   mistakes
 

	
 
 - jumping to an individual transaction in a large ledger isn't trivial - Emacs
 
   grep mode is the current best option
 

	
 
 - not all staff use Emacs
 

	
 
 - auditors would prefer Bradley didn't perform reconciliation, ideally not
 
   Rosanne either
 

	
 
 - reconciliation reports are created by hand when there are mismatches
 

	
 
Other related problems we're not dealing with here:
 

	
 
 - after updates to the books files, beancount must be restarted to reflect
 
   updates
 

	
 
 - updates also invalidate the cache meaning restart takes several minutes
 

	
 
 - balance checks are manually updated in
 
   svn/Financial/Ledger/sanity-check-balances.yaml
 

	
 
 - transactions are entered manually and reconciled after the fact, but
 
   importing from statements may be useful in some cases
 

	
 
Current issue:
 

	
 
 - entry_point seems to swallow errors, meaning you get a fairly unhelpful
 
   message if there's an unhandled error
 

	
 
Future possibilities:
 

	
 
 - allow the reconciler to respect manually-applied metadata - not clear how
 
   this would work exactly
 

	
 
 - allow interactive matching where the user can specifiy a match
 

	
 
 - consider combining this with helper.py into one more complete tool that both
 
   reconciles and summarises the unreconciled transactions
 
"""
 

	
 
# TODO:
 
#  - entry_point seems to swallow errors
 
#  - extract the magic numbers
 

	
 
import argparse
 
import collections
 
import copy
 
import csv
 
import datetime
 
import decimal
 
import io
 
import itertools
 
import logging
 
import os
 
import re
 
import sys
 
from typing import Dict, List, Optional, Sequence, Tuple, TextIO
 

	
 
from beancount import loader
 
from beancount.query.query import run_query
 
from colorama import Fore, Style  # type: ignore
 

	
 
from .. import cliutil
 
from .. import config as configmod
 

	
 
if not sys.warnoptions:
 
    import warnings
 
    # Disable annoying warning from thefuzz prompting for a C extension. The
 
    # current pure-Python implementation isn't a bottleneck for us.
 
    warnings.filterwarnings('ignore', category=UserWarning, module='thefuzz.fuzz')
 
from thefuzz import fuzz  # type: ignore
 

	
 
PROGNAME = 'reconcile-statement'
 
logger = logging.getLogger(__name__)
 

	
 
# Get some interesting feedback on call to RT with this:
 
# logger.setLevel(logging.DEBUG)
 
# logger.addHandler(logging.StreamHandler())
 

	
 
JUNK_WORDS = [
 
    'software',
 
    'freedom',
 
    'conservancy',
 
    'conse',
 
    'payment',
 
    'echeck',
 
    'bill',
 
    'debit',
 
    'wire',
 
    'credit',
 
    "int'l",
 
    "in.l",
 
    'llc',
 
    'online',
 
    'donation',
 
    'usd',
 
    'inc',
 
]
 
JUNK_WORDS_RES = [re.compile(word, re.IGNORECASE) for word in JUNK_WORDS]
 
ZERO_RE = re.compile('^0+')
 
FULL_MATCH_THRESHOLD = 0.8
 
PARTIAL_MATCH_THRESHOLD = 0.4
 

	
 

	
 
def remove_duplicate_words(text: str) -> str:
 
    unique_words = []
 
    known_words = set()
 
    for word in text.split():
 
        if word.lower() not in known_words:
 
            unique_words.append(word)
 
            known_words.add(word.lower())
 
    return ' '.join(unique_words)
 

	
 

	
 
def remove_payee_junk(payee: str) -> str:
 
    """Clean up payee field to improve quality of fuzzy matching.
 

	
 
    It turns out that bank statement "description" fields are
 
    difficult to fuzzy match on because they're long and
 
    noisey. Truncating them (see standardize_XXX_record fns) and
 
    removing the common junk helps significantly.
 

	
 
    """
 
    for r in JUNK_WORDS_RES:
 
        payee = r.sub('', payee)
 
    payee = ZERO_RE.sub('', payee)
 
    payee = payee.replace(' - ', ' ')
 
    payee = re.sub(r'\.0\.\d+', ' ', payee)
 
    payee = payee.replace('.0', ' ')
 
    payee = payee.replace('/', ' ')
 
    payee = re.sub(re.escape('.com'), ' ', payee, flags=re.IGNORECASE)
 
    payee = re.sub(re.escape('.net'), ' ', payee, flags=re.IGNORECASE)
 
    payee = payee.replace('*', ' ')
 
    payee = ' '.join([i for i in payee.split(' ') if len(i) > 2])
 
    payee = payee.replace('-', ' ')
 
    payee = remove_duplicate_words(payee)
 
    payee.strip()
 
    return payee
 

	
 

	
 
def parse_amount(amount: str) -> decimal.Decimal:
 
    """Parse amounts and handle comma separators as seen in some FR statements."""
 
    return decimal.Decimal(amount.replace('$', '').replace(',', ''))
 

	
 

	
 
def validate_amex_csv(sample: str) -> None:
 
    required_cols = {'Date', 'Amount', 'Description', 'Card Member'}
 
    reader = csv.DictReader(io.StringIO(sample))
 
    if reader.fieldnames and not required_cols.issubset(reader.fieldnames):
 
        sys.exit(f"This AMEX CSV doesn't seem to have the columns we're expecting, including: {', '.join(required_cols)}. Please use an unmodified statement direct from the institution.")
...
 
@@ -307,99 +310,99 @@ def first_word_exact_match(a: str, b: str) -> float:
 
    if len(a) == 0 or len(b) == 0:
 
        return 0.0
 
    first_a = a.split()[0].strip()
 
    first_b = b.split()[0].strip()
 
    if first_a.casefold() == first_b.casefold():
 
        return min(1.0, 0.2 * len(first_a))
 
    else:
 
        return 0.0
 

	
 

	
 
def payee_match(a: str, b: str) -> float:
 
    """Score a match between two payees."""
 
    fuzzy_match = float(fuzz.token_set_ratio(a, b) / 100.00)
 
    first_word_match = first_word_exact_match(a, b)
 
    return max(fuzzy_match, first_word_match)
 

	
 

	
 
def records_match(r1: Dict, r2: Dict) -> Tuple[float, List[str]]:
 
    """Do these records represent the same transaction?"""
 
    date_score = date_proximity(r1['date'], r2['date'])
 
    if r1['date'] == r2['date']:
 
        date_message = ''
 
    elif date_score > 0.0:
 
        diff = abs((r1['date'] - r2['date']).days)
 
        date_message = f'+/- {diff} days'
 
    else:
 
        date_message = 'date mismatch'
 

	
 
    if r1['amount'] == r2['amount']:
 
        amount_score, amount_message = 2.0, ''
 
    else:
 
        amount_score, amount_message = 0.0, 'amount mismatch'
 

	
 
    # We never consider payee if there's a check_id in the books.
 
    check_message = ''
 
    payee_message = ''
 
    # Sometimes we get unrelated numbers in the statement column with check-ids,
 
    # so we can't match based on the existence of a statement check-id.
 
    if r2['check_id']:
 
        payee_score = 0.0
 
        if r1['check_id'] and r2['check_id'] and r1['check_id'] == r2['check_id']:
 
            check_score = 1.0
 
        else:
 
            check_message = 'check-id mismatch'
 
            check_score = 0.0
 
    else:
 
        check_score = 0.0
 
        payee_score = payee_match(r1['payee'], r2['payee'])
 
        if payee_score > 0.8:
 
        if payee_score > FULL_MATCH_THRESHOLD:
 
            payee_message = ''
 
        elif payee_score > 0.4:
 
        elif payee_score > PARTIAL_MATCH_THRESHOLD:
 
            payee_message = 'partial payee match'
 
        else:
 
            payee_message = 'payee mismatch'
 

	
 
    overall_score = (date_score + amount_score + check_score + payee_score) / 4
 
    overall_message = [m for m in [date_message, amount_message, check_message, payee_message] if m]
 
    return overall_score, overall_message
 

	
 

	
 
def match_statement_and_books(statement_trans: List[Dict], books_trans: List[Dict]) -> Tuple[List[Tuple[List, List, List]], List[Dict], List[Dict]]:
 
    """Match transactions between the statement and books.
 

	
 
    If matched, the books transaction is marked off so that it can
 
    only be matched once. Some transactions will be matched, some will
 
    be on the statement but not the books and some on the books but
 
    not the statement.
 

	
 
    Passes through any unmatched transactions.
 

	
 
    Currently we use the same matching logic for all types of
 
    statements. It's conceivable that you could have special cases to
 
    accurately match some types of statements, but that would be more
 
    work to maintain and test.
 

	
 
    """
 
    matches = []
 
    remaining_books_trans = []
 
    remaining_statement_trans = []
 

	
 
    for r1 in statement_trans:
 
        best_match_score = 0.0
 
        best_match_index = None
 
        best_match_note = []
 
        matches_found = 0
 
        for i, r2 in enumerate(books_trans):
 
            score, note = records_match(r1, r2)
 
            if score >= 0.5 and score >= best_match_score:
 
                matches_found += 1
 
                best_match_score = score
 
                best_match_index = i
 
                best_match_note = note
 
        if best_match_score > 0.5 and matches_found == 1 and 'check-id mismatch' not in best_match_note or best_match_score > 0.8:
 
            matches.append(([r1], [books_trans[best_match_index]], best_match_note))
 
            # Don't try to make a second match against this books entry.
 
            if best_match_index is not None:
 
                del books_trans[best_match_index]
 
        else:
 
            remaining_statement_trans.append(r1)
0 comments (0 inline, 0 general)