// 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 data for one member in one month. type FeeData struct { Expected int IsUnknown bool // true when junior has exactly 1 session (manual review; Python sentinel "?") Attendance int JuniorAttendance int // junior-tab sessions; used for the :NJ,MA breakdown in the juniors view AdultAttendance int // adult-tab sessions for J-tier members; used for the :NJ,MA breakdown } // 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 ManualFix string // "manual fix" column; non-empty disables re-inference 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 VS string // Variabilní symbol (Czech variable payment symbol) Message string BankID string SyncID 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 IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions OriginalExpected int AttendanceCount int JuniorAttendance int // junior-tab sessions; for :NJ,MA breakdown in juniors view AdultAttendance int // adult-tab sessions; for :NJ,MA breakdown 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, IsUnknown: fd.IsUnknown, OriginalExpected: originalExpected, AttendanceCount: attendanceCount, JuniorAttendance: fd.JuniorAttendance, AdultAttendance: fd.AdultAttendance, 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, } }