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
+25 -2
View File
@@ -3,6 +3,7 @@ package matching
import (
"fmt"
"fuj-management/go/internal/domain/czech"
"strings"
"time"
)
@@ -17,12 +18,22 @@ type Transaction struct {
}
// InferredDetails is the result of InferTransactionDetails.
// Months holds "YYYY-MM" keys, or exactly one "other:…" purpose when the
// payment is not a monthly fee (see IsOtherPurpose).
type InferredDetails struct {
Members []Match
Months []string
SearchText string
}
// IsOtherPurpose reports whether s 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.
// Ports legacy/scripts/match_payments.py is_other_purpose.
func IsOtherPurpose(s string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")
}
// InferTransactionDetails infers which member(s) and month(s) a transaction belongs to.
//
// Search text for member matching: sender + message + user_id.
@@ -30,6 +41,10 @@ type InferredDetails struct {
// Fallback 1: if no members found, retry match on sender alone.
// Fallback 2: if no months found, derive from tx.Date (Sheets serial or YYYY-MM-DD).
//
// 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.
//
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
//
@@ -38,12 +53,20 @@ func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear i
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
members := MatchMembers(searchText, memberNames)
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
if len(members) == 0 {
members = MatchMembers(tx.Sender, memberNames)
}
if IsOtherPurpose(tx.Message) {
return InferredDetails{
Members: members,
Months: []string{strings.TrimSpace(tx.Message)},
SearchText: searchText,
}
}
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
if len(months) == 0 && tx.Date != nil && tx.Date != "" {
if ym := inferMonthFromDate(tx.Date); ym != "" {
months = []string{ym}
+68
View File
@@ -32,6 +32,74 @@ import (
var inferMembers = []string{"Tomáš Němeček (Tov)", "Jana Nováková"}
func TestIsOtherPurpose(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"other: tournament": true,
"other:shirt": true,
" Other: Tournament": true,
"OTHER:x": true,
"leden other: x": false,
"other tournament": false,
"another: x": false,
"2026-01": false,
"": false,
}
for in, want := range cases {
if got := IsOtherPurpose(in); got != want {
t.Errorf("IsOtherPurpose(%q) = %v, want %v", in, got, want)
}
}
}
func TestInferTransactionDetails_OtherMessage(t *testing.T) {
t.Parallel()
jana := []Match{{Name: "Jana Nováková", Confidence: ConfidenceAuto}}
cases := []struct {
name string
tx Transaction
wantMembers []Match
wantMonths []string
}{
{
// Month words and the date must both be ignored.
name: "other prefix: message becomes the purpose, no month",
tx: Transaction{Sender: "Jana Novakova", Message: " other: turnaj leden 2026 ", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"other: turnaj leden 2026"},
},
{
name: "other prefix is case-insensitive and keeps the original text",
tx: Transaction{Sender: "Jana Novakova", Message: "Other: Dres", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"Other: Dres"},
},
{
name: "other prefix with no known member still yields the purpose",
tx: Transaction{Sender: "Unknown Payer", Message: "other: dres", Date: "2026-04-15"},
wantMembers: nil,
wantMonths: []string{"other: dres"},
},
{
name: "other: not at the start is handled the old way",
tx: Transaction{Sender: "Jana Novakova", Message: "leden other: dres", Date: "2026-04-15"},
wantMembers: jana,
wantMonths: []string{"2026-01"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := InferTransactionDetails(tc.tx, inferMembers, 2026)
if len(got.Members) != len(tc.wantMembers) || (len(got.Members) > 0 && !reflect.DeepEqual(got.Members, tc.wantMembers)) {
t.Errorf("Members = %v, want %v", got.Members, tc.wantMembers)
}
if !reflect.DeepEqual(got.Months, tc.wantMonths) {
t.Errorf("Months = %v, want %v", got.Months, tc.wantMonths)
}
})
}
}
func TestInferTransactionDetails(t *testing.T) {
t.Parallel()