Files
fuj-management/go/internal/domain/reconcile/reconcile.go
T
kacerrandClaude Opus 5.5 022facc957
Deploy to K8s / deploy (push) Successful in 20s
refactor: move Python implementation to legacy/, make Go primary
Go (go/) becomes the primary implementation. The original Python
version moves to legacy/ and is kept runnable; new features are still
built in both (see the Dual implementation rule in CLAUDE.md).

- git mv app.py, scripts/, templates/, tests/, pyproject.toml, uv.lock
  and build/ into legacy/
- Makefile: Python targets point at legacy/, venv in legacy/.venv,
  CREDENTIALS_PATH exported for the root .secret/ file
- CI and make image build the Python image with legacy/ as context;
  the Makefile is no longer copied into the image
- legacy/.dockerignore moved to the context root so it takes effect
- CLAUDE.md, docs/README.md, Go comments, fixture README, capture
  script and VS Code launch config updated to the new layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:33:57 +02:00

404 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package reconcile ports the three-phase payment reconciliation from legacy/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 legacy/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 legacy/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,
}
}