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>
193 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|