Files
fuj-management/go/internal/domain/matching/infer_test.go
T
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

177 lines
6.6 KiB
Go

package matching
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=legacy/scripts:legacy python3 << 'EOF'
// from match_payments import infer_transaction_details
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
// cases = [
// ({"sender":"Tomas Nemecek","message":"clenske 04/2026","user_id":"","date":"2026-04-15"}, "full match"),
// ({"sender":"Tomas Nemecek","message":"","user_id":"","date":"2026-04-15"}, "sender fallback month"),
// ({"sender":"Jana Novakova","message":"","user_id":"","date":44197}, "serial int date"),
// ({"sender":"neznamy","message":"","user_id":"","date":""}, "no match"),
// ({"sender":"Tomas Nemecek","message":"","user_id":"","date":44197.5}, "serial float date"),
// ]
// for tx, label in cases:
// r = infer_transaction_details(tx, MEMBERS)
// print(label + ": members=" + repr(r["members"]) + " months=" + repr(r["months"]) + " search_text=" + repr(r["search_text"]))
// EOF
//
// Output:
//
// full match: members=[('Tomáš Němeček (Tov)', 'auto')] months=['2026-04'] search_text='Tomas Nemecek clenske 04/2026 '
// sender fallback month: members=[('Tomáš Němeček (Tov)', 'auto')] months=['2026-04'] search_text='Tomas Nemecek '
// serial int date: members=[('Jana Nováková', 'auto')] months=['2021-01'] search_text='Jana Novakova '
// no match: members=[] months=[] search_text='neznamy '
// serial float date: members=[('Tomáš Němeček (Tov)', 'auto')] months=['2021-01'] search_text='Tomas Nemecek '
import (
"reflect"
"testing"
)
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()
cases := []struct {
name string
tx Transaction
defaultYear int
wantMembers []Match
wantMonths []string
wantSearchText string
}{
{
name: "full match — members and months from search text",
tx: Transaction{Sender: "Tomas Nemecek", Message: "clenske 04/2026", UserID: "", Date: "2026-04-15"},
defaultYear: 2026,
wantMembers: []Match{{Name: "Tomáš Němeček (Tov)", Confidence: ConfidenceAuto}},
wantMonths: []string{"2026-04"},
// Python: sender + " " + message + " " + user_id (no trim)
wantSearchText: "Tomas Nemecek clenske 04/2026 ",
},
{
// months not in message → fall back to date string
name: "months fall back to date string",
tx: Transaction{Sender: "Tomas Nemecek", Message: "", UserID: "", Date: "2026-04-15"},
defaultYear: 2026,
wantMembers: []Match{{Name: "Tomáš Němeček (Tov)", Confidence: ConfidenceAuto}},
wantMonths: []string{"2026-04"},
wantSearchText: "Tomas Nemecek ",
},
{
// months fall back to Sheets serial int date
name: "months fall back to serial int date",
tx: Transaction{Sender: "Jana Novakova", Message: "", UserID: "", Date: int(44197)},
defaultYear: 2026,
wantMembers: []Match{{Name: "Jana Nováková", Confidence: ConfidenceAuto}},
wantMonths: []string{"2021-01"},
wantSearchText: "Jana Novakova ",
},
{
// months fall back to Sheets serial float64 date
name: "months fall back to serial float date",
tx: Transaction{Sender: "Tomas Nemecek", Message: "", UserID: "", Date: float64(44197.5)},
defaultYear: 2026,
wantMembers: []Match{{Name: "Tomáš Němeček (Tov)", Confidence: ConfidenceAuto}},
wantMonths: []string{"2021-01"},
wantSearchText: "Tomas Nemecek ",
},
{
name: "no match — both slices empty not nil",
tx: Transaction{Sender: "neznamy", Message: "", UserID: "", Date: ""},
defaultYear: 2026,
wantMembers: []Match{},
wantMonths: []string{},
wantSearchText: "neznamy ",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := InferTransactionDetails(tc.tx, inferMembers, tc.defaultYear)
if !reflect.DeepEqual(got.Members, tc.wantMembers) {
t.Errorf("Members\n got %v\n want %v", got.Members, tc.wantMembers)
}
if !reflect.DeepEqual(got.Months, tc.wantMonths) {
t.Errorf("Months\n got %v\n want %v", got.Months, tc.wantMonths)
}
if got.SearchText != tc.wantSearchText {
t.Errorf("SearchText\n got %q\n want %q", got.SearchText, tc.wantSearchText)
}
})
}
}