# Payments Matcher, read the bank once and match every payment to what it paid

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

```python
# 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.
