All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Replace proportional split with a fill-first loop that allocates min(remaining, deficit) to each matched month in user-supplied order, where deficit = expected - already_paid. Prior transactions' contributions are now properly accounted for, so a second payment on overlapping months fills only what's still owed instead of splitting proportionally by total expected. Surplus after all deficits are covered goes to the credit bucket. Fixes: Matyáš Thér 200+550 showing 566/183 instead of 500/250. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
404 lines
12 KiB
Go
404 lines
12 KiB
Go
// 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 two allocation phases:
|
||
// 1. Fill-first: iterate matched months in user-supplied order, allocating min(remaining,
|
||
// deficit) to each month where deficit = expected − already-paid. Surplus → credit.
|
||
// Handles both the "greedy" (payment covers all) and "partial" cases in one pass.
|
||
// 2. 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 {
|
||
// Fill-first: iterate inWindow in matched-months order (chronological by
|
||
// convention), allocating min(remaining, deficit) to each month. Deficit
|
||
// is net of what prior transactions already paid, so a second payment on
|
||
// the same months correctly fills only what remains due. Any surplus after
|
||
// all deficits are covered goes to the credit bucket.
|
||
remaining := inWindowShare
|
||
for _, mw := range inWindow {
|
||
md := ledger[memberName][mw.month]
|
||
deficit := float64(mw.expected) - md.Paid
|
||
if deficit < 0 {
|
||
deficit = 0
|
||
}
|
||
alloc := remaining
|
||
if deficit < alloc {
|
||
alloc = deficit
|
||
}
|
||
if alloc <= 0 {
|
||
continue
|
||
}
|
||
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
|
||
remaining -= alloc
|
||
}
|
||
if remaining > 0 {
|
||
credits[memberName] += int(remaining)
|
||
}
|
||
} 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,
|
||
}
|
||
}
|