Files
kacerrandClaude Opus 5.5 bbad19d822 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>
2026-10-08 12:37:03 +02:00

134 lines
5.8 KiB
Python

import unittest
from unittest.mock import MagicMock, patch
from scripts.match_payments import (
canonical_member_key, fetch_sheet_data, infer_transaction_details, is_other_purpose, reconcile,
)
class TestCanonicalMemberKey(unittest.TestCase):
def test_diacritics_and_case_collapse(self):
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
self.assertEqual(canonical_member_key("MARIA MACO"), "maria maco")
self.assertEqual(canonical_member_key("maria maco"), "maria maco")
def test_whitespace_runs_collapse(self):
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
self.assertEqual(canonical_member_key(" Mária Maco "), "maria maco")
def test_unknown_name_passes_through_normalized(self):
# Two genuinely different names must not collide.
self.assertNotEqual(
canonical_member_key("Mária Maco"),
canonical_member_key("Marek Maco"),
)
class TestReconcileTolerantPersonMatching(unittest.TestCase):
def _members(self):
return [("Mária Maco", "A", {"2026-04": (750, 4)})]
def _tx(self, person):
return {
"date": "2026-04-15",
"amount": 750,
"person": person,
"purpose": "2026-04",
"inferred_amount": 750,
"sender": "Maco Family",
"message": "fee",
}
def test_person_without_diacritics_matches(self):
result = reconcile(self._members(), ["2026-04"], [self._tx("Maria Maco")], {})
member = result["members"]["Mária Maco"]
self.assertEqual(member["months"]["2026-04"]["paid"], 750)
self.assertEqual(len(member["months"]["2026-04"]["transactions"]), 1)
self.assertEqual(result["unmatched"], [])
def test_person_with_extra_whitespace_matches(self):
result = reconcile(self._members(), ["2026-04"], [self._tx("Mária Maco")], {})
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
self.assertEqual(result["unmatched"], [])
def test_person_lowercase_matches(self):
result = reconcile(self._members(), ["2026-04"], [self._tx("mária maco")], {})
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
self.assertEqual(result["unmatched"], [])
def test_truly_unknown_person_still_unmatched(self):
result = reconcile(
self._members(), ["2026-04"], [self._tx("Někdo Neznámý")], {}
)
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 0)
self.assertEqual(len(result["unmatched"]), 1)
if __name__ == "__main__":
unittest.main()
class TestFetchSheetDataVS(unittest.TestCase):
"""Numeric VS cells arrive as floats (UNFORMATTED_VALUE) and must stay plain digit strings."""
def _fetch(self, vs_cell):
service = MagicMock()
service.spreadsheets().values().get().execute.return_value = {"values": [
["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount",
"Sender", "VS", "Message", "Bank ID", "Sync ID"],
["2026-04-15", 750, "", "", "", "", "Test Sender", vs_cell, "fee", "", "abc"],
]}
with patch("scripts.match_payments.get_sheets_service", return_value=service):
return fetch_sheet_data("sheet-id", "creds.json")
def test_numeric_vs_is_plain_digit_string(self):
self.assertEqual(self._fetch(2609767878.0)[0]["vs"], "2609767878")
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"], [])