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>
113 lines
3.4 KiB
Go
113 lines
3.4 KiB
Go
package matching
|
|
|
|
import (
|
|
"fmt"
|
|
"fuj-management/go/internal/domain/czech"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Transaction is the subset of a payment row used by InferTransactionDetails.
|
|
// Date accepts string ("YYYY-MM-DD"), float64 (Sheets serial), or int — matching
|
|
// the heterogeneous types returned by the Sheets API and the FIO scraper.
|
|
type Transaction struct {
|
|
Sender string
|
|
Message string
|
|
UserID string
|
|
Date any
|
|
}
|
|
|
|
// 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.
|
|
// Month search text: message + user_id only (sender excluded, matching Python).
|
|
// 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).
|
|
//
|
|
// Ports legacy/scripts/match_payments.py infer_transaction_details.
|
|
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
|
|
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
|
|
|
members := MatchMembers(searchText, memberNames)
|
|
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}
|
|
}
|
|
}
|
|
|
|
if months == nil {
|
|
months = []string{}
|
|
}
|
|
|
|
return InferredDetails{
|
|
Members: members,
|
|
Months: months,
|
|
SearchText: searchText,
|
|
}
|
|
}
|
|
|
|
// inferMonthFromDate converts a date value to "YYYY-MM" for the month fallback.
|
|
// Returns "" on any error, matching Python's bare except pass.
|
|
func inferMonthFromDate(val any) string {
|
|
switch v := val.(type) {
|
|
case int:
|
|
dt := sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour)))
|
|
return dt.Format("2006-01")
|
|
case int64:
|
|
dt := sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour)))
|
|
return dt.Format("2006-01")
|
|
case float64:
|
|
dt := sheetsEpoch.Add(time.Duration(v * 24 * float64(time.Hour)))
|
|
return dt.Format("2006-01")
|
|
case string:
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
dt, err := time.Parse("2006-01-02", v)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return dt.Format("2006-01")
|
|
default:
|
|
return ""
|
|
}
|
|
}
|