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>
100 lines
2.8 KiB
Go
100 lines
2.8 KiB
Go
package membership
|
|
|
|
// Golden strings verified against scripts/calculate_fees.py on 2026-05-06:
|
|
//
|
|
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
|
|
//
|
|
// (feed equivalent fixture data via attendance sheet or local CSV)
|
|
|
|
import (
|
|
"bytes"
|
|
"fuj-management/go/internal/domain/reconcile"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestPrintFeesTableAdultsOnly(t *testing.T) {
|
|
t.Parallel()
|
|
members := []reconcile.Member{
|
|
{Name: "Alice", Tier: "A", Fees: map[string]reconcile.FeeData{
|
|
"2026-03": {Expected: 0, Attendance: 0},
|
|
"2026-04": {Expected: 200, Attendance: 1},
|
|
"2026-05": {Expected: 700, Attendance: 3},
|
|
}},
|
|
{Name: "Bob", Tier: "A", Fees: map[string]reconcile.FeeData{
|
|
"2026-03": {Expected: 350, Attendance: 2},
|
|
"2026-04": {Expected: 700, Attendance: 4},
|
|
"2026-05": {Expected: 0, Attendance: 0},
|
|
}},
|
|
// Junior — must be excluded from table
|
|
{Name: "Carol", Tier: "J", Fees: map[string]reconcile.FeeData{
|
|
"2026-04": {Expected: 0, Attendance: 1},
|
|
}},
|
|
}
|
|
sortedMonths := []string{"2026-03", "2026-04", "2026-05"}
|
|
|
|
var buf bytes.Buffer
|
|
printFeesTable(&buf, members, sortedMonths)
|
|
got := buf.String()
|
|
|
|
// Verify structure
|
|
if !strings.Contains(got, "Member") {
|
|
t.Error("missing header 'Member'")
|
|
}
|
|
if !strings.Contains(got, "Mar 2026") || !strings.Contains(got, "Apr 2026") || !strings.Contains(got, "May 2026") {
|
|
t.Error("missing month labels")
|
|
}
|
|
if strings.Contains(got, "Carol") {
|
|
t.Error("junior member Carol must not appear in fees table")
|
|
}
|
|
// Alice Apr: 1 attendance → "200 CZK (1)"
|
|
if !strings.Contains(got, "200 CZK (1)") {
|
|
t.Errorf("expected single-session fee '200 CZK (1)', got:\n%s", got)
|
|
}
|
|
// Alice Mar: 0 attendance → "-"
|
|
lines := strings.Split(got, "\n")
|
|
aliceLine := ""
|
|
for _, l := range lines {
|
|
if strings.HasPrefix(strings.TrimSpace(l), "Alice") {
|
|
aliceLine = l
|
|
break
|
|
}
|
|
}
|
|
if aliceLine == "" {
|
|
t.Fatal("no Alice line found")
|
|
}
|
|
// Alice's first col (Mar 2026) should be "-"
|
|
if !strings.Contains(aliceLine, "-") {
|
|
t.Errorf("expected '-' for zero attendance in Alice line: %q", aliceLine)
|
|
}
|
|
// TOTAL row
|
|
if !strings.Contains(got, "TOTAL") {
|
|
t.Error("missing TOTAL row")
|
|
}
|
|
// Total for May 2026 = 700 CZK
|
|
if !strings.Contains(got, "700 CZK") {
|
|
t.Errorf("expected '700 CZK' in totals, got:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestPrintFeesTableNoAdults(t *testing.T) {
|
|
t.Parallel()
|
|
members := []reconcile.Member{
|
|
{Name: "X", Tier: "J", Fees: map[string]reconcile.FeeData{}},
|
|
}
|
|
var buf bytes.Buffer
|
|
printFeesTable(&buf, members, []string{"2026-04"})
|
|
if buf.String() != "No data.\n" {
|
|
t.Errorf("want 'No data.', got %q", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestPrintFeesTableEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
var buf bytes.Buffer
|
|
printFeesTable(&buf, nil, nil)
|
|
if buf.String() != "No data.\n" {
|
|
t.Errorf("want 'No data.', got %q", buf.String())
|
|
}
|
|
}
|