feat(infer): messages starting with "other:" are not monthly fees

When the bank message starts with "other:" (case-insensitive),
inference does not look for a month, neither in the text nor from the
payment date. The trimmed message is written to Purpose, so reconcile
books the payment as an "other" transaction for the person instead of
a monthly fee. Messages containing "other:" elsewhere are unchanged.

- matching.IsOtherPurpose / is_other_purpose shared by inference and
  reconcile
- reconcile applies the rule to rows not yet processed by inference
- no person found: Purpose is still written, row shows as unmatched
- implemented in Go and legacy Python; four new parity fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:37:03 +02:00
co-authored by Claude Opus 5.5
parent 294c40fa42
commit bbad19d822
15 changed files with 567 additions and 10 deletions
+38
View File
@@ -358,6 +358,36 @@ SEEDS[("infer_transaction_details", "serial_date")] = {
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_prefix")] = {
"tx": {
"sender": "Jana Novakova",
"message": "other: tournament leden 2026",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_prefix_uppercase")] = {
"tx": {
"sender": "Test Payer",
"message": " Other: Dres ",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "other_not_at_start")] = {
"tx": {
"sender": "Jana Novakova",
"message": "leden other: dres",
"user_id": "",
"date": {"type": "string", "value": "2026-04-15"},
},
"member_names": _ROSTER,
"default_year": 2026,
}
SEEDS[("infer_transaction_details", "no_member_no_month")] = {
"tx": {
"sender": "Unknown Person",
@@ -476,6 +506,14 @@ SEEDS[("reconcile", "07_other_purpose_split")] = _reconcile_seed(
transactions=[_tx("2026-01-10", 800, "Alice Dvořák, Bob Kratochvíl", "other:tournament", sender="Alice Dvořák")],
)
# 11 — message starts with "other:" on a row not yet inferred (empty Person/Purpose):
# goes to the other ledger, the month stays unpaid
SEEDS[("reconcile", "11_other_message_fallback")] = _reconcile_seed(
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
sorted_months=["2026-01"],
transactions=[_tx("2026-01-10", 300, "", "", sender="Alice Dvořák", message="other: tournament")],
)
# 08 — junior with attendance=1 (expected=0 in reconcile, unknown in UI)
SEEDS[("reconcile", "08_junior_question_mark")] = _reconcile_seed(
members=[_member("Karel Junior", "A", {"2026-01": (0, 1)})],
+31 -6
View File
@@ -150,27 +150,48 @@ def match_members(
# Reconciliation
# ---------------------------------------------------------------------------
def is_other_purpose(text) -> bool:
"""True when text marks a payment that is not a monthly fee.
It starts with "other:" (case-insensitive, leading whitespace ignored).
Used for both the Purpose column and the bank message.
"""
return str(text or "").strip().lower().startswith("other:")
def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
"""Infer member(s) and month(s) for a single transaction.
A message that starts with "other:" is not a monthly fee: no month is
inferred (neither from the text nor from the date) and 'months' holds the
trimmed message as the single purpose.
Returns:
{
'members': [(name, confidence)],
'months': [YYYY-MM],
'months': [YYYY-MM] or ['other:…'],
'matched_text': str
}
"""
# Combine sender + message for searching
search_text = f"{tx.get('sender', '')} {tx.get('message', '')} {tx.get('user_id', '')}"
matched_members = match_members(search_text, member_names)
matched_months = parse_month_references(
tx.get("message", "") + " " + tx.get("user_id", "")
)
if not matched_members:
# Try matching sender name alone with more lenient matching
matched_members = match_members(tx.get("sender", ""), member_names)
if is_other_purpose(tx.get("message", "")):
return {
"members": matched_members,
"months": [str(tx.get("message", "")).strip()],
"search_text": search_text
}
matched_months = parse_month_references(
tx.get("message", "") + " " + tx.get("user_id", "")
)
if not matched_months:
# If no month specified, try to infer from payment date
tx_date = tx.get("date")
@@ -394,7 +415,7 @@ def reconcile(
# Strip markers like [?]
person_str = re.sub(r"\[\?\]\s*", "", person_str)
is_other = purpose_str.lower().startswith("other:")
is_other = is_other_purpose(purpose_str)
if person_str and purpose_str:
# We have pre-matched data (either from script or manual)
@@ -415,6 +436,10 @@ def reconcile(
inference = infer_transaction_details(tx, member_names)
matched_members = inference["members"]
matched_months = inference["months"]
# A message starting with "other:" is inferred as a non-fee payment.
if len(matched_months) == 1 and is_other_purpose(matched_months[0]):
is_other = True
purpose_str = matched_months[0]
amount = tx.get("amount", 0)
try:
amount = float(amount)