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:
@@ -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)})],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from scripts.match_payments import canonical_member_key, fetch_sheet_data, reconcile
|
||||
from scripts.match_payments import (
|
||||
canonical_member_key, fetch_sheet_data, infer_transaction_details, is_other_purpose, reconcile,
|
||||
)
|
||||
|
||||
|
||||
class TestCanonicalMemberKey(unittest.TestCase):
|
||||
@@ -88,3 +90,44 @@ class TestFetchSheetDataVS(unittest.TestCase):
|
||||
|
||||
def test_string_vs_is_kept(self):
|
||||
self.assertEqual(self._fetch("0308")[0]["vs"], "0308")
|
||||
|
||||
|
||||
class TestOtherPurposeInference(unittest.TestCase):
|
||||
"""A message starting with "other:" is not a monthly fee."""
|
||||
|
||||
MEMBERS = ["Jana Nováková", "Adam Novák"]
|
||||
|
||||
def test_is_other_purpose(self):
|
||||
for text in ("other: tournament", "other:shirt", " Other: Tournament", "OTHER:x"):
|
||||
self.assertTrue(is_other_purpose(text), text)
|
||||
for text in ("leden other: x", "other tournament", "another: x", "2026-01", "", None):
|
||||
self.assertFalse(is_other_purpose(text), text)
|
||||
|
||||
def test_message_becomes_purpose_and_no_month_is_inferred(self):
|
||||
tx = {"sender": "Jana Novakova", "message": " other: turnaj leden 2026 ", "date": "2026-04-15"}
|
||||
result = infer_transaction_details(tx, self.MEMBERS)
|
||||
self.assertEqual(result["members"], [("Jana Nováková", "auto")])
|
||||
self.assertEqual(result["months"], ["other: turnaj leden 2026"])
|
||||
|
||||
def test_unknown_member_still_yields_purpose(self):
|
||||
tx = {"sender": "Unknown Payer", "message": "Other: Dres", "date": "2026-04-15"}
|
||||
result = infer_transaction_details(tx, self.MEMBERS)
|
||||
self.assertEqual(result["members"], [])
|
||||
self.assertEqual(result["months"], ["Other: Dres"])
|
||||
|
||||
def test_other_not_at_start_is_handled_the_old_way(self):
|
||||
tx = {"sender": "Jana Novakova", "message": "leden other: dres", "date": "2026-04-15"}
|
||||
result = infer_transaction_details(tx, self.MEMBERS)
|
||||
self.assertEqual(result["months"], ["2026-01"])
|
||||
|
||||
def test_reconcile_fallback_puts_it_in_other_ledger(self):
|
||||
members = [("Jana Nováková", "A", {"2026-01": (750, 3)})]
|
||||
tx = {"date": "2026-01-10", "amount": 300, "person": "", "purpose": "",
|
||||
"sender": "Jana Novakova", "message": "other: dres", "inferred_amount": ""}
|
||||
result = reconcile(members, ["2026-01"], [tx], {})
|
||||
member = result["members"]["Jana Nováková"]
|
||||
self.assertEqual(member["months"]["2026-01"]["paid"], 0)
|
||||
self.assertEqual(len(member["other_transactions"]), 1)
|
||||
self.assertEqual(member["other_transactions"][0]["amount"], 300)
|
||||
self.assertEqual(member["other_transactions"][0]["purpose"], "other: dres")
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
Reference in New Issue
Block a user