Files
fuj-management/go/internal/services/membership/format_reconcile.go
Jan Novak 56aa2303a8 feat(go/M2.11-12): wire fuj fees + fuj reconcile subcommands
Add internal/services/membership package: AttendanceLoader,
TransactionLoader, ExceptionLoader interfaces + NewStubSources stub
(returns ErrIOPending until M4 lands real Sheets loaders).

FeesReport and ReconcileReport orchestrate domain/fees + domain/reconcile
and write fixed-width text reports matching Python calculate_fees.py and
match_payments.py print_report output. 13 unit tests cover all formatter
branches and orchestration wiring via fake loaders.

cmd/fuj/main.go: fees and reconcile subcommands now dispatch; sync/infer
retain the [M4] placeholder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 17:50:31 +02:00

193 lines
5.0 KiB
Go

package membership
import (
"fmt"
"fuj-management/go/internal/domain/reconcile"
"io"
"sort"
"strings"
"time"
)
// printReconcileReport writes the full balance report to w.
// Mirrors scripts/match_payments.py print_report().
//
// Verify with:
//
// PYTHONPATH=scripts:. python -c '
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
// ...'
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
monthLabel := func(m string) string {
t, err := time.Parse("2006-01", m)
if err != nil {
return m
}
return t.Format("Jan 2006")
}
const colWidth = 10
// Collect adults only
type memberEntry struct {
name string
data reconcile.MemberResult
}
var adults []memberEntry
for name, data := range result.Members {
if data.Tier == "A" {
adults = append(adults, memberEntry{name: name, data: data})
}
}
sort.Slice(adults, func(i, j int) bool { return adults[i].name < adults[j].name })
// Header banner
fmt.Fprintln(w, strings.Repeat("=", 80))
fmt.Fprintln(w, "PAYMENT RECONCILIATION REPORT")
fmt.Fprintln(w, strings.Repeat("=", 80))
// Name column width
nameWidth := 20
for _, e := range adults {
if len(e.name) > nameWidth {
nameWidth = len(e.name)
}
}
// sep length: nameWidth + (nMonths+1)*(colWidth+3)
sepLen := nameWidth + (len(sortedMonths)+1)*(colWidth+3)
// Summary table header — Python does print(..., end="") then print(f" | {'Balance':>10}")
fmt.Fprintf(w, "\n%-*s", nameWidth, "Member")
for _, m := range sortedMonths {
fmt.Fprintf(w, " | %*s", colWidth, monthLabel(m))
}
fmt.Fprintf(w, " | %*s\n", colWidth, "Balance")
fmt.Fprintln(w, strings.Repeat("-", sepLen))
var totalExpected, totalPaid int
for _, e := range adults {
fmt.Fprintf(w, "%-*s", nameWidth, e.name)
memberBalance := 0
for _, m := range sortedMonths {
md := e.data.Months[m]
expected := md.Expected
paid := int(md.Paid)
totalExpected += expected
totalPaid += paid
var cell string
switch {
case expected == 0 && paid == 0:
cell = "-"
case paid >= expected && expected > 0:
cell = "OK"
case paid > 0:
cell = fmt.Sprintf("%d/%d", paid, expected)
default:
cell = fmt.Sprintf("UNPAID %d", expected)
}
memberBalance += paid - expected
fmt.Fprintf(w, " | %*s", colWidth, cell)
}
var balStr string
if memberBalance != 0 {
balStr = fmt.Sprintf("%+d", memberBalance)
} else {
balStr = "0"
}
fmt.Fprintf(w, " | %*s\n", colWidth, balStr)
}
// TOTAL footer
fmt.Fprintln(w, strings.Repeat("-", sepLen))
fmt.Fprintf(w, "%-*s", nameWidth, "TOTAL")
for range sortedMonths {
fmt.Fprintf(w, " | %*s", colWidth, "")
}
balance := totalPaid - totalExpected
fmt.Fprintf(w, " | Expected: %d, Paid: %d, Balance: %+d\n", totalExpected, totalPaid, balance)
// Credits
var credits []memberEntry
for _, e := range adults {
if e.data.TotalBalance > 0 {
credits = append(credits, e)
}
}
// also non-adult members with positive balance
for name, data := range result.Members {
if data.Tier != "A" && data.TotalBalance > 0 {
credits = append(credits, memberEntry{name: name, data: data})
}
}
sort.Slice(credits, func(i, j int) bool { return credits[i].name < credits[j].name })
if len(credits) > 0 {
fmt.Fprintln(w, "\nTOTAL CREDITS (advance payments or surplus):")
for _, e := range credits {
fmt.Fprintf(w, " %s: %d CZK\n", e.name, e.data.TotalBalance)
}
}
// Debts
var debts []memberEntry
for _, e := range adults {
if e.data.TotalBalance < 0 {
debts = append(debts, e)
}
}
for name, data := range result.Members {
if data.Tier != "A" && data.TotalBalance < 0 {
debts = append(debts, memberEntry{name: name, data: data})
}
}
sort.Slice(debts, func(i, j int) bool { return debts[i].name < debts[j].name })
if len(debts) > 0 {
fmt.Fprintln(w, "\nTOTAL DEBTS (missing payments):")
for _, e := range debts {
fmt.Fprintf(w, " %s: %d CZK\n", e.name, -e.data.TotalBalance)
}
}
// Unmatched transactions
if len(result.Unmatched) > 0 {
fmt.Fprintln(w, "\nUNMATCHED TRANSACTIONS (need manual review)")
fmt.Fprintf(w, " %-12s %10s %-30s %s\n", "Date", "Amount", "Sender", "Message")
fmt.Fprintf(w, " %-12s %10s %-30s %-30s\n",
strings.Repeat("-", 12), strings.Repeat("-", 10),
strings.Repeat("-", 30), strings.Repeat("-", 30))
for _, tx := range result.Unmatched {
fmt.Fprintf(w, " %-12s %10.0f %-30s %s\n",
tx.Date, tx.Amount, tx.Sender, tx.Message)
}
}
// Matched transaction details
fmt.Fprintln(w, "\nMATCHED TRANSACTION DETAILS")
for _, e := range adults {
hasPayments := false
for _, m := range sortedMonths {
if len(e.data.Months[m].Transactions) > 0 {
hasPayments = true
break
}
}
if !hasPayments {
continue
}
fmt.Fprintf(w, "\n %s:\n", e.name)
for _, m := range sortedMonths {
for _, tx := range e.data.Months[m].Transactions {
conf := ""
if tx.Confidence == "review" {
conf = " [REVIEW]"
}
fmt.Fprintf(w, " %s: %.0f CZK from %s — \"%s\"%s\n",
monthLabel(m), tx.Amount, tx.Sender, tx.Message, conf)
}
}
}
}