diff --git a/go/internal/domain/reconcile/reconcile.go b/go/internal/domain/reconcile/reconcile.go new file mode 100644 index 0000000..4089f87 --- /dev/null +++ b/go/internal/domain/reconcile/reconcile.go @@ -0,0 +1,393 @@ +// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py. +package reconcile + +import ( + "fuj-management/go/internal/domain/czech" + "fuj-management/go/internal/domain/matching" + "regexp" + "strings" +) + +// ExceptionKey identifies a fee override by normalized member name and period. +type ExceptionKey struct { + Name string // czech.Normalize(memberName) + Period string // czech.Normalize("YYYY-MM") +} + +// Exception is a manual fee override for one member in one period. +type Exception struct { + Amount int + Note string +} + +// FeeData holds the expected fee and attendance count for one member in one month. +type FeeData struct { + Expected int + Attendance int +} + +// Member is one row from the attendance sheet. +type Member struct { + Name string + Tier string + Fees map[string]FeeData // month ("YYYY-MM") → fee data +} + +// Transaction is one payment row from the payments sheet. +// Date must already be a "YYYY-MM-DD" string (convert with matching.FormatDate before calling). +// InferredAmount, when non-nil, replaces Amount when person and purpose are pre-matched. +type Transaction struct { + Date string + Amount float64 + Person string // comma-separated canonical names (empty → use inference) + Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference) + InferredAmount *float64 // nil → fall back to Amount + Sender string + Message string + UserID string +} + +// TxEntry is the portion of a payment allocated to a single member+month. +type TxEntry struct { + Amount float64 + Date string + Sender string + Message string + Confidence string +} + +// OtherEntry is a payment with purpose "other:…" allocated to a member. +type OtherEntry struct { + Amount float64 + Date string + Sender string + Message string + Purpose string + Confidence string +} + +// MonthData is the ledger state for one member in one month. +type MonthData struct { + Expected int + OriginalExpected int + AttendanceCount int + Exception *Exception + Paid float64 + Transactions []TxEntry +} + +// MemberResult is the reconciled ledger for one member. +type MemberResult struct { + Tier string + Months map[string]MonthData + OtherTransactions []OtherEntry + TotalBalance int +} + +// Result is the top-level output of Reconcile. +type Result struct { + Members map[string]MemberResult + Unmatched []Transaction + Credits map[string]int // final balance for every member (may be negative) +} + +var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`) + +// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key +// used to resolve Person-column values that drift from canonical attendance-sheet names. +// Ports scripts/match_payments.py canonical_member_key. +func canonicalMemberKey(name string) string { + return strings.Join(strings.Fields(czech.Normalize(name)), " ") +} + +type monthExpected struct { + month string + expected int +} + +// Reconcile matches transactions to members and months using three allocation phases: +// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit. +// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder. +// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally. +// +// defaultYear seeds czech.ParseMonthReferences in the inference fallback. +// Pass time.Now().Year() in production; pass a fixed year in tests. +// +// Ports scripts/match_payments.py reconcile. +func Reconcile( + members []Member, + sortedMonths []string, + transactions []Transaction, + exceptions map[ExceptionKey]Exception, + defaultYear int, +) Result { + memberNames := make([]string, len(members)) + memberTiers := make(map[string]string, len(members)) + memberFees := make(map[string]map[string]FeeData, len(members)) + + for i, m := range members { + memberNames[i] = m.Name + memberTiers[m.Name] = m.Tier + memberFees[m.Name] = m.Fees + } + + // Map canonical key → first attendance-sheet name with that key, so Person cells + // that drift in diacritics/case/whitespace still resolve to the canonical name. + canonicalByKey := make(map[string]string, len(memberNames)) + for _, name := range memberNames { + key := canonicalMemberKey(name) + if _, exists := canonicalByKey[key]; !exists { + canonicalByKey[key] = name + } + } + + if exceptions == nil { + exceptions = map[ExceptionKey]Exception{} + } + + // Initialise ledger + ledger := make(map[string]map[string]MonthData, len(memberNames)) + otherLedger := make(map[string][]OtherEntry, len(memberNames)) + + for _, name := range memberNames { + ledger[name] = make(map[string]MonthData, len(sortedMonths)) + otherLedger[name] = []OtherEntry{} + for _, m := range sortedMonths { + fd := memberFees[name][m] + originalExpected := fd.Expected + attendanceCount := fd.Attendance + + var expected int + var exInfo *Exception + exKey := ExceptionKey{ + Name: czech.Normalize(name), + Period: czech.Normalize(m), + } + if ex, ok := exceptions[exKey]; ok { + expected = ex.Amount + exCopy := ex + exInfo = &exCopy + } else { + expected = originalExpected + } + + ledger[name][m] = MonthData{ + Expected: expected, + OriginalExpected: originalExpected, + AttendanceCount: attendanceCount, + Exception: exInfo, + Paid: 0, + Transactions: []TxEntry{}, + } + } + } + + var unmatched []Transaction + credits := make(map[string]int, len(memberNames)) + + for _, tx := range transactions { + personStr := strings.TrimSpace(tx.Person) + purposeStr := strings.TrimSpace(tx.Purpose) + personStr = questionMarkRe.ReplaceAllString(personStr, "") + isOther := strings.HasPrefix(strings.ToLower(purposeStr), "other:") + + var matchedMembers []matching.Match + var matchedMonths []string + var amount float64 + + if personStr != "" && purposeStr != "" { + for p := range strings.SplitSeq(personStr, ",") { + p = strings.TrimSpace(p) + if p != "" { + matchedMembers = append(matchedMembers, matching.Match{ + Name: p, + Confidence: matching.ConfidenceAuto, + }) + } + } + if isOther { + matchedMonths = []string{purposeStr} + } else { + for m := range strings.SplitSeq(purposeStr, ",") { + m = strings.TrimSpace(m) + if m != "" { + matchedMonths = append(matchedMonths, m) + } + } + } + if tx.InferredAmount != nil { + amount = *tx.InferredAmount + } else { + amount = tx.Amount + } + } else { + // Inference fallback for rows not yet processed by infer_payments.py + inferred := matching.InferTransactionDetails( + matching.Transaction{ + Sender: tx.Sender, + Message: tx.Message, + UserID: tx.UserID, + Date: tx.Date, + }, + memberNames, + defaultYear, + ) + matchedMembers = inferred.Members + matchedMonths = inferred.Months + amount = tx.Amount + } + + if len(matchedMembers) == 0 || len(matchedMonths) == 0 { + unmatched = append(unmatched, tx) + continue + } + + if isOther { + nAlloc := len(matchedMembers) + perAlloc := 0.0 + if nAlloc > 0 { + perAlloc = amount / float64(nAlloc) + } + for _, m := range matchedMembers { + memberName := canonicalByKey[canonicalMemberKey(m.Name)] + if memberName != "" { + otherLedger[memberName] = append(otherLedger[memberName], OtherEntry{ + Amount: perAlloc, + Date: tx.Date, + Sender: tx.Sender, + Message: tx.Message, + Purpose: purposeStr, + Confidence: string(m.Confidence), + }) + } + } + continue + } + + memberShare := 0.0 + if len(matchedMembers) > 0 { + memberShare = amount / float64(len(matchedMembers)) + } + + for _, m := range matchedMembers { + memberName := canonicalByKey[canonicalMemberKey(m.Name)] + if memberName == "" { + unmatched = append(unmatched, tx) + continue + } + + var inWindow []monthExpected + outCount := 0 + for _, month := range matchedMonths { + if md, ok := ledger[memberName][month]; ok { + inWindow = append(inWindow, monthExpected{month: month, expected: md.Expected}) + } else { + outCount++ + } + } + + nTotal := len(matchedMonths) + outCredit := 0.0 + if outCount > 0 && nTotal > 0 { + outCredit = memberShare / float64(nTotal) * float64(outCount) + credits[memberName] += int(outCredit) + } + + inWindowShare := memberShare - outCredit + + if len(inWindow) == 0 { + continue + } + + totalExpected := 0 + for _, mw := range inWindow { + totalExpected += mw.expected + } + + if totalExpected > 0 && inWindowShare >= float64(totalExpected) { + // Greedy: payment covers all expected fees; overflow → credit + credits[memberName] += int(inWindowShare - float64(totalExpected)) + for _, mw := range inWindow { + alloc := float64(mw.expected) + md := ledger[memberName][mw.month] + md.Paid += alloc + md.Transactions = append(md.Transactions, TxEntry{ + Amount: alloc, + Date: tx.Date, + Sender: tx.Sender, + Message: tx.Message, + Confidence: string(m.Confidence), + }) + ledger[memberName][mw.month] = md + } + } else if totalExpected > 0 { + // Proportional: distribute by each month's share; last month absorbs float remainder + remaining := inWindowShare + for i, mw := range inWindow { + var alloc float64 + if i == len(inWindow)-1 { + alloc = remaining + } else { + alloc = inWindowShare * float64(mw.expected) / float64(totalExpected) + } + remaining -= alloc + md := ledger[memberName][mw.month] + md.Paid += alloc + md.Transactions = append(md.Transactions, TxEntry{ + Amount: alloc, + Date: tx.Date, + Sender: tx.Sender, + Message: tx.Message, + Confidence: string(m.Confidence), + }) + ledger[memberName][mw.month] = md + } + } else { + // Even-split fallback: prepayment before attendance recorded + perMonth := inWindowShare / float64(len(inWindow)) + for _, mw := range inWindow { + md := ledger[memberName][mw.month] + md.Paid += perMonth + md.Transactions = append(md.Transactions, TxEntry{ + Amount: perMonth, + Date: tx.Date, + Sender: tx.Sender, + Message: tx.Message, + Confidence: string(m.Confidence), + }) + ledger[memberName][mw.month] = md + } + } + } + } + + // Final total balances: window balance + out-of-window credits accumulated above + finalBalances := make(map[string]int, len(memberNames)) + for _, name := range memberNames { + windowBalance := 0 + for _, mdata := range ledger[name] { + windowBalance += int(mdata.Paid) - mdata.Expected + } + finalBalances[name] = windowBalance + credits[name] + } + + membersResult := make(map[string]MemberResult, len(memberNames)) + for _, name := range memberNames { + membersResult[name] = MemberResult{ + Tier: memberTiers[name], + Months: ledger[name], + OtherTransactions: otherLedger[name], + TotalBalance: finalBalances[name], + } + } + + if unmatched == nil { + unmatched = []Transaction{} + } + + return Result{ + Members: membersResult, + Unmatched: unmatched, + Credits: finalBalances, + } +} diff --git a/go/internal/domain/reconcile/reconcile_test.go b/go/internal/domain/reconcile/reconcile_test.go new file mode 100644 index 0000000..edaa2ff --- /dev/null +++ b/go/internal/domain/reconcile/reconcile_test.go @@ -0,0 +1,376 @@ +package reconcile + +// Expected values verified against scripts/match_payments.py on 2026-05-06: +// +// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v +// +// All Python test cases are ported below. Additional Go-only cases are marked with [Go]. + +import ( + "math" + "testing" +) + +const defaultYear = 2026 + +// tx builds a pre-matched Transaction (person+purpose already filled in). +// InferredAmount is left nil so Amount is used directly, matching the Python +// _tx helper where inferred_amount == amount. +func tx(person, purpose string, amount float64) Transaction { + return Transaction{ + Date: "2026-01-01", + Amount: amount, + Person: person, + Purpose: purpose, + Sender: "Sender", + Message: "fee", + } +} + +func TestReconcileExceptionOverride(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}} + exceptions := map[ExceptionKey]Exception{ + {Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"}, + } + txs := []Transaction{{ + Date: "2026-01-05", Amount: 400, + Person: "Alice", Purpose: "2026-01", Sender: "Alice Sender", Message: "fee", + }} + + result := Reconcile(members, []string{"2026-01"}, txs, exceptions, defaultYear) + + jan := result.Members["Alice"].Months["2026-01"] + if jan.Expected != 400 { + t.Errorf("Expected override to 400, got %d", jan.Expected) + } + if jan.Paid != 400 { + t.Errorf("Paid want 400, got %f", jan.Paid) + } + if result.Members["Alice"].TotalBalance != 0 { + t.Errorf("TotalBalance want 0, got %d", result.Members["Alice"].TotalBalance) + } +} + +func TestReconcileFallbackToAttendance(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}} + + result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear) + + if result.Members["Alice"].Months["2026-01"].Expected != 750 { + t.Errorf("Expected 750 when no exception, got %d", result.Members["Alice"].Months["2026-01"].Expected) + } +} + +func TestReconcileGreedyExactMatch(t *testing.T) { + t.Parallel() + members := []Member{{ + Name: "Alice", Tier: "A", + Fees: map[string]FeeData{ + "2026-02": {750, 3}, + "2026-03": {350, 3}, + "2026-04": {150, 2}, + }, + }} + sortedMonths := []string{"2026-02", "2026-03", "2026-04"} + + result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, nil, defaultYear) + + months := result.Members["Alice"].Months + if int(months["2026-02"].Paid) != 750 { + t.Errorf("2026-02 paid want 750, got %f", months["2026-02"].Paid) + } + if int(months["2026-03"].Paid) != 350 { + t.Errorf("2026-03 paid want 350, got %f", months["2026-03"].Paid) + } + if int(months["2026-04"].Paid) != 150 { + t.Errorf("2026-04 paid want 150, got %f", months["2026-04"].Paid) + } +} + +func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) { + t.Parallel() + members := []Member{{ + Name: "Alice", Tier: "A", + Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}}, + }} + sortedMonths := []string{"2026-01", "2026-02"} + + result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-01, 2026-02", 2000)}, nil, defaultYear) + + months := result.Members["Alice"].Months + if int(months["2026-01"].Paid) != 750 { + t.Errorf("2026-01 paid want 750, got %f", months["2026-01"].Paid) + } + if int(months["2026-02"].Paid) != 750 { + t.Errorf("2026-02 paid want 750, got %f", months["2026-02"].Paid) + } + if result.Credits["Alice"] != 500 { + t.Errorf("credits want 500, got %d", result.Credits["Alice"]) + } +} + +func TestReconcileProportionalUnderpayment(t *testing.T) { + t.Parallel() + members := []Member{{ + Name: "Alice", Tier: "A", + Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}}, + }} + sortedMonths := []string{"2026-02", "2026-03", "2026-04"} + amount := 1250.0 + + result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", amount)}, nil, defaultYear) + + months := result.Members["Alice"].Months + paid02 := months["2026-02"].Paid + paid03 := months["2026-03"].Paid + paid04 := months["2026-04"].Paid + + if paid02 >= 750 { + t.Errorf("2026-02 should be underpaid, got %f", paid02) + } + if paid03 >= 350 { + t.Errorf("2026-03 should be underpaid, got %f", paid03) + } + if paid04 >= 750 { + t.Errorf("2026-04 should be underpaid, got %f", paid04) + } + if math.Abs(paid02+paid03+paid04-amount) > 0.01 { + t.Errorf("sum of paid want %f, got %f", amount, paid02+paid03+paid04) + } + if math.Abs(paid02-paid04) > 0.01 { + t.Errorf("02 and 04 have equal expected, want equal paid: %f vs %f", paid02, paid04) + } +} + +func TestReconcileSingleMonthUnchanged(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + + result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear) + + if math.Abs(result.Members["Alice"].Months["2026-01"].Paid-750) > 0.01 { + t.Errorf("single month want 750, got %f", result.Members["Alice"].Months["2026-01"].Paid) + } +} + +func TestReconcileTwoMembersMultiMonth(t *testing.T) { + t.Parallel() + members := []Member{ + {Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}}, + {Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}}, + } + sortedMonths := []string{"2026-01", "2026-02"} + + result := Reconcile(members, sortedMonths, []Transaction{tx("Alice, Bob", "2026-01, 2026-02", 2200)}, nil, defaultYear) + + for _, name := range []string{"Alice", "Bob"} { + months := result.Members[name].Months + if math.Abs(months["2026-01"].Paid-750) > 0.01 { + t.Errorf("%s 2026-01 paid want 750, got %f", name, months["2026-01"].Paid) + } + if math.Abs(months["2026-02"].Paid-350) > 0.01 { + t.Errorf("%s 2026-02 paid want 350, got %f", name, months["2026-02"].Paid) + } + } +} + +func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) { + t.Parallel() + members := []Member{{ + Name: "Alice", Tier: "A", + Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}}, + }} + sortedMonths := []string{"2026-01", "2026-02"} + + result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-01, 2026-02", 300)}, nil, defaultYear) + + months := result.Members["Alice"].Months + if math.Abs(months["2026-01"].Paid-150) > 0.01 { + t.Errorf("2026-01 paid want 150, got %f", months["2026-01"].Paid) + } + if math.Abs(months["2026-02"].Paid-150) > 0.01 { + t.Errorf("2026-02 paid want 150, got %f", months["2026-02"].Paid) + } +} + +func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}} + txFn := func(person string) Transaction { + return Transaction{ + Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04", + Sender: "Maco Family", Message: "fee", + } + } + + cases := []struct { + name string + person string + }{ + {"without diacritics", "Maria Maco"}, + {"extra whitespace", "Mária Maco"}, + {"lowercase", "mária maco"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := Reconcile(members, []string{"2026-04"}, []Transaction{txFn(tc.person)}, nil, defaultYear) + + paid := result.Members["Mária Maco"].Months["2026-04"].Paid + if paid != 750 { + t.Errorf("%s: paid want 750, got %f", tc.name, paid) + } + if len(result.Unmatched) != 0 { + t.Errorf("%s: want no unmatched, got %v", tc.name, result.Unmatched) + } + }) + } +} + +func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}} + txs := []Transaction{{ + Date: "2026-04-15", Amount: 750, + Person: "Někdo Neznámý", Purpose: "2026-04", + Sender: "Neznámý", Message: "fee", + }} + + result := Reconcile(members, []string{"2026-04"}, txs, nil, defaultYear) + + if result.Members["Mária Maco"].Months["2026-04"].Paid != 0 { + t.Errorf("unknown person must not credit the member") + } + if len(result.Unmatched) != 1 { + t.Errorf("want 1 unmatched, got %d", len(result.Unmatched)) + } +} + +// [Go] Test that [?] markers are stripped from the Person field before lookup. +func TestReconcileQuestionMarkMarkerStripped(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + txs := []Transaction{{ + Date: "2026-01-01", Amount: 750, + Person: "[?] Alice", Purpose: "2026-01", + Sender: "Bank", Message: "fee", + }} + + result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear) + + if result.Members["Alice"].Months["2026-01"].Paid != 750 { + t.Errorf("[?] stripping: want 750 paid, got %f", result.Members["Alice"].Months["2026-01"].Paid) + } +} + +// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger. +func TestReconcileOtherPurpose(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + txs := []Transaction{{ + Date: "2026-01-01", Amount: 300, + Person: "Alice", Purpose: "other:shirt", + Sender: "Bank", Message: "shirt order", + }} + + result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear) + + if result.Members["Alice"].Months["2026-01"].Paid != 0 { + t.Errorf("other: purpose must not touch month ledger") + } + others := result.Members["Alice"].OtherTransactions + if len(others) != 1 { + t.Fatalf("want 1 OtherTransaction, got %d", len(others)) + } + if math.Abs(others[0].Amount-300) > 0.01 { + t.Errorf("OtherEntry.Amount want 300, got %f", others[0].Amount) + } + if others[0].Purpose != "other:shirt" { + t.Errorf("OtherEntry.Purpose want %q, got %q", "other:shirt", others[0].Purpose) + } +} + +// [Go] Months outside sortedMonths go to credit, not to the window ledger. +func TestReconcileOutOfWindowGoesToCredit(t *testing.T) { + t.Parallel() + // Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out). + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}} + txs := []Transaction{{ + Date: "2026-01-01", Amount: 1200, + Person: "Alice", Purpose: "2026-01, 2026-02", + Sender: "Bank", Message: "Q1", + }} + + result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear) + + // member_share = 1200 (one member) + // out_credit = 1200 / 2 * 1 = 600 + // in_window_share = 600 + // in_window = [(2026-01, 600)], total_expected = 600 → greedy: paid = 600, no overflow + if math.Abs(result.Members["Alice"].Months["2026-01"].Paid-600) > 0.01 { + t.Errorf("in-window paid want 600, got %f", result.Members["Alice"].Months["2026-01"].Paid) + } + // total_balance = int(600) - 600 (window) + 600 (out credit) = 600 + if result.Members["Alice"].TotalBalance != 600 { + t.Errorf("TotalBalance want 600, got %d", result.Members["Alice"].TotalBalance) + } +} + +// [Go] No person/purpose → inference fallback resolves sender name and date month. +func TestReconcileInferenceFallback(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}} + txs := []Transaction{{ + Date: "2026-04-15", Amount: 750, + // Person and Purpose are empty → inference path + Sender: "Tomas Nemecek", + Message: "clenske 04/2026", + }} + + result := Reconcile(members, []string{"2026-04"}, txs, nil, defaultYear) + + if math.Abs(result.Members["Tomáš Němeček"].Months["2026-04"].Paid-750) > 0.01 { + t.Errorf("inference fallback: want 750 paid, got %f", result.Members["Tomáš Němeček"].Months["2026-04"].Paid) + } +} + +// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged. +func TestReconcileNoMatchGoesToUnmatched(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + txs := []Transaction{{ + Date: "2026-01-01", Amount: 500, + // empty person+purpose and sender name not matching any member + Sender: "Unknown Corp", Message: "invoice", + }} + + result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear) + + if len(result.Unmatched) != 1 { + t.Errorf("want 1 unmatched, got %d", len(result.Unmatched)) + } + if result.Members["Alice"].Months["2026-01"].Paid != 0 { + t.Errorf("unmatched tx must not touch ledger") + } +} + +// [Go] Empty transaction list leaves every month at paid=0 and balance=–expected. +func TestReconcileNoTransactionsAllUnpaid(t *testing.T) { + t.Parallel() + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + + result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear) + + if result.Members["Alice"].Months["2026-01"].Paid != 0 { + t.Errorf("no txs: want paid=0, got %f", result.Members["Alice"].Months["2026-01"].Paid) + } + if result.Members["Alice"].TotalBalance != -750 { + t.Errorf("no txs: want balance -750, got %d", result.Members["Alice"].TotalBalance) + } + if len(result.Unmatched) != 0 { + t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched) + } +}