A sanitised reference implementation. Drop it into Claude Code, point it at your own bank feed and invoice table, adapt the reference rules. Generic on purpose, with no business specifics, no real payees and no real amounts.
The problem
A bank feed and an invoice list both hold the same fact, that something got paid, but nothing ties them together automatically. Someone ends up reading a statement line by line, working out by eye which invoice it must be, and marking it paid by hand. It is slow, and a short payment or an odd reference is easy to miss.
The idea
Read your own already stored copy of the bank feed, never the bank itself live, and try each payment against your invoices two ways in order. First, pull a likely invoice number out of the payment's own reference text and check the amount agrees exactly. If the reference is no help, fall back to matching on amount alone, but only when exactly one open invoice is that amount. Anything that still does not land cleanly, a short payment, more than one invoice at that amount, a reference that matches nothing, goes on a short list for a person to glance at. Nothing is ever guessed into place.
bank feed ──> [reference match] ──> exact amount? ──> write the match
│ no match
v
[amount match] ──> exactly one open invoice that size? ──> write the match
│ no
v
add to the needs you list, nothing written
Minimal reference implementation
# payments_matcher.py, reference only. Swap in your own bank rows and invoice table.
import re
def invoice_from_reference(reference):
"""The longest run of digits in the reference, read as the invoice number.
Handles a reference typed oddly (INV0123, 1NV-0456, INV-00789) without
needing an exact format. None when there is no such run."""
runs = re.findall(r"\d+", reference or "")
if not runs:
return None
return max(runs, key=len)
def match_payment(payment, open_invoices):
"""One payment, in whole pennies, against open invoices. Returns (invoice,
rule) or (None, reason) when a person needs to look at it."""
number = invoice_from_reference(payment["reference"])
by_number = {inv["number"]: inv for inv in open_invoices}
if number and number in by_number:
inv = by_number[number]
if inv["amount"] == payment["amount"]:
return inv, "reference_and_amount"
return None, f"names invoice {number} but the amount does not agree"
same_amount = [inv for inv in open_invoices if inv["amount"] == payment["amount"]]
if len(same_amount) == 1:
return same_amount[0], "amount_only"
if len(same_amount) > 1:
return None, f"{len(same_amount)} open invoices are that exact amount"
return None, "no open invoice matches this payment"
def run(payments, open_invoices, write_match, needs_you):
for payment in payments:
inv, result = match_payment(payment, open_invoices)
if inv:
write_match(payment, inv, result) # your own write, never the bank
else:
needs_you(payment, result) # a short list, not a guess
Why it works
- Reference first, amount second, never amount alone by default. A reference match plus an exact amount is about as certain as this gets. Amount alone is only safe when it is unique, so it stays the fallback, never the rule.
- Compares in whole pennies, never in pounds. Converting to an integer before comparing means two amounts are either exactly equal or they are not, with no rounding grey area to argue with.
- A short, specific reason travels with every unmatched payment. "No open invoice matches this payment" is enough for a person to act on in seconds. A bare unmatched flag is not.
- Nothing is ever deleted. Undoing a match stamps it undone rather than removing it, so the record it paid can reopen cleanly, and the tool will not silently try the exact same link again on its own.
- A person's word is a nudge to check, never the record. Someone saying they paid an invoice is a reason to run the matcher again, not a reason to mark it paid before the bank actually shows it.
Adapt it
- Write your own reference rules per payer or payee. The shape here is generic on purpose.
- Add a sum of invoices fallback for a payer who is known to pay several at once in a single transfer.
- Keep the needs you list to a handful of lines, not a dump. The value is that a person reads it in under a minute and knows exactly what to look at.
Built for my own group's accounts, runs every morning, and this is the generalised version. Take it, point it at your own bank feed, tell me what you would add.