A bank statement line gives you an amount, a date, a sender, and a free-text reference field, and your system has to decide which user that money belongs to. The reference field is wrong, truncated or missing often enough that attribution built on it alone leaves you with a growing pile of money you cannot assign to anybody.
I have built this layer for account-to-account flows and for virtual bank-account ledgering, where each user holds their own accounts and every incoming transfer has to land on exactly one of them. The failure mode is undramatic. Nothing crashes, no alert fires. You simply accumulate unattributed credits, support tickets from users who paid and see no balance, and a reconciliation report that nobody can close at the end of the day.
The constraint that makes it hard
Attribution is not a search problem, it is a money problem. A card authorization is a conversation: you send a request, you get an identifier back, you can query that identifier later. An incoming transfer is a monologue. The money is already in the account when you hear about it. Whatever you decide, you decide from a row of text produced by somebody else's system, and the person who typed the reference is not the person who formatted it.
That asymmetry sets two rules I now treat as non-negotiable. First, an automatic attribution must be provable from a value you yourself issued, not inferred from resemblance. Crediting user A with user B's money is a compliance incident, not a bug you patch next sprint. Second, unattributed money must have somewhere to live that is visible, ordered and aging, because it will exist no matter how good the matcher is.
Why the reference field cannot be trusted
Everything upstream of you is hostile to a free-text field. People retype the code from a screenshot and turn O into 0 and 1 into I. Banks truncate the field or strip punctuation. Corporate accounting departments overwrite the reference with their own invoice number, because their internal process needs that field more than yours does. A single transfer arrives covering two of your invoices, or a payment lands from a spouse's account, or from a company account whose name has no relationship to the user's name at all.
So the first design decision is to stop asking the reference field to carry identity when there is a better channel available.
Route first: the account is the identifier
If you can issue a dedicated virtual account per user, attribution stops being text processing and becomes routing. The user sends money to the account that belongs only to them, and the credited account on the statement line is the answer. The reference field becomes a hint for humans rather than a key for machines.
This is the single largest reduction in attribution work I have seen in production, and it also removes an entire class of support conversation, because the user no longer has to copy anything correctly. It is not always available, and legacy inflow to a shared collection account continues for years after you introduce it, so you still need the fallback path below. But every transfer you move onto a dedicated account is a transfer that never enters the queue.
When you must use a code, make it machine-checkable
On a shared collection account, the reference is all you have. The mistake is to put a raw internal identifier in it. A raw identifier is silently valid when mistyped: change one digit and you get another perfectly real user. A short code with a check character turns almost every human transcription error into a clean rejection instead of a wrong credit.
I encode over an alphabet that omits the characters people confuse, and I normalise aggressively before parsing.
ALPHABET = "0123456789ABCDEFGHJKLMNPQRSTVWXYZ" # no I, L, O, U
BASE = len(ALPHABET)
BODY_LEN = 5
LOOKALIKES = str.maketrans({"I": "1", "L": "1", "O": "0"})
def _check_char(body: str) -> str:
total = sum((i + 1) * ALPHABET.index(c) for i, c in enumerate(body))
return ALPHABET[total % BASE]
def encode_reference(user_id: int) -> str:
n, digits = user_id, []
while n:
n, rem = divmod(n, BASE)
digits.append(ALPHABET[rem])
body = "".join(reversed(digits)).rjust(BODY_LEN, ALPHABET[0])
return "PAY" + body + _check_char(body)
def decode_reference(text: str) -> int | None:
cleaned = "".join(text.upper().split()).translate(LOOKALIKES)
cleaned = "".join(c for c in cleaned if c.isalnum())
marker = cleaned.find("PAY")
if marker < 0:
return None
token = cleaned[marker + 3 : marker + 3 + BODY_LEN + 1]
if len(token) != BODY_LEN + 1:
return None
body, check = token[:BODY_LEN], token[BODY_LEN]
if check != _check_char(body):
return None
value = 0
for c in body:
if c not in ALPHABET:
return None
value = value * BASE + ALPHABET.index(c)
return value
The PAY prefix matters more than it looks. Without an anchor, you scan an arbitrary sentence for anything that could be a code, and corporate invoice numbers will happily satisfy a length-and-charset test. With an anchor plus a check character, a false positive requires deliberate effort.
Attribution is an ordered list of rules, not a score
I have seen the scoring approach several times and I no longer build it. A weighted score over account, amount, name similarity and timing produces a number that nobody can defend when an operator asks why a specific transfer landed on a specific user. Rules that run in a fixed order produce an answer plus the name of the rule that produced it, and that name goes into the ledger entry.
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class StatementLine:
external_id: str
amount: Decimal
credited_account: str
sender_account: str
sender_name: str
reference: str
@dataclass(frozen=True)
class Attribution:
user_id: int
rule: str
def by_virtual_account(line: StatementLine, repo) -> Attribution | None:
user_id = repo.user_for_virtual_account(line.credited_account)
return Attribution(user_id, "virtual_account") if user_id else None
def by_reference_code(line: StatementLine, repo) -> Attribution | None:
user_id = decode_reference(line.reference)
if user_id is None or not repo.user_exists(user_id):
return None
return Attribution(user_id, "reference_code")
def by_confirmed_sender(line: StatementLine, repo) -> Attribution | None:
owners = repo.users_with_confirmed_sender_account(line.sender_account)
if len(owners) != 1:
return None
return Attribution(owners[0], "confirmed_sender_account")
AUTOMATIC_RULES = (by_virtual_account, by_reference_code, by_confirmed_sender)
def attribute(line: StatementLine, repo) -> Attribution | None:
for rule in AUTOMATIC_RULES:
result = rule(line, repo)
if result is not None:
return result
return None
Note what is missing. There is no name-similarity rule in the automatic path. Sender names are a ranking signal for a human reviewing candidates, never a reason to move money on their own. The confirmed-sender rule only fires when exactly one user has previously been linked to that sender account through an attribution a human or a stronger rule already made — ambiguity returns None rather than picking a winner.
The unmatched queue is a product surface
Everything that returns None goes into a queue, and that queue deserves the same care as a checkout page. It needs the statement line verbatim, ranked candidate users with the reason each was suggested, an age, and an operator action that is itself an auditable event. Age is the metric that matters: a queue measured by depth looks fine while the oldest item quietly turns into a chargeback or a complaint. Sort by oldest, not by largest.
The suggestion side is where fuzzy matching belongs, because a human is the safety mechanism.
def suggest(line: StatementLine, repo, limit: int = 5):
scored = []
for user in repo.users_with_open_expectation(line.amount):
score = 0
if user.sender_accounts and line.sender_account in user.sender_accounts:
score += 50
score += name_similarity(user.display_name, line.sender_name) # 0..40
if repo.recently_initiated_transfer(user.id):
score += 10
scored.append((score, user))
scored.sort(key=lambda pair: pair[0], reverse=True)
return [user for _, user in scored[:limit]]
Reattribution is a reversal, not an update
Operators will get some of these wrong, and users will call about it weeks later. If attribution is a mutable column on the payment row, correcting it destroys the evidence of what happened, and your statement-level reconciliation stops agreeing with your ledger.
Attribution is an event. Correcting it means posting the opposite of the original entry and then posting the new one, both carrying the statement line identifier.
def reattribute(session, line, wrong_user_id, right_user_id, operator_id):
session.add_all([
LedgerEntry(
statement_line_id=line.external_id,
user_id=wrong_user_id,
amount=-line.amount,
rule="reattribution_reversal",
actor=operator_id,
),
LedgerEntry(
statement_line_id=line.external_id,
user_id=right_user_id,
amount=line.amount,
rule="reattribution",
actor=operator_id,
),
])
The sum of all ledger entries for a statement line then equals that line's amount, always, whatever happened in between. That single invariant is what makes the daily reconciliation a query rather than an investigation.
What I would do differently
I would issue per-user virtual accounts from the first day rather than adding them once the shared collection account became painful, because the migration is not technical, it is a matter of getting every existing user to change where they send money — and some of them never will.
I would also record the rule name from the very first version. On an early system, attribution was a boolean and the rule lived only in code, so when the matcher changed, no one could tell which historical credits came from which logic. Storing the rule per entry costs one column and answers the only question that gets asked during an audit.
The last thing I would change is expectations. Early on I treated the unmatched queue as a defect to be driven to zero. It is not. Transfers from third-party accounts, partial payments and overwritten references are normal traffic in any account-to-account flow. The queue is a permanent part of the system, and it should be built like one.
Originally published on polycratia.com — where I write about payment systems, crypto rails and marketplace backends.
Top comments (0)