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>
14 KiB
M2.11 + M2.12 — fuj fees and fuj reconcile subcommands (stubbed IO)
On approval: copy this plan to
docs/plans/2026-05-06-1738-go-m2-11-12-fees-reconcile-cli.mdper CLAUDE.md plan-location convention.
Context
The Go rewrite (tracked in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) finished M2.1–M2.10 — every pure-domain helper (czech, fees, money, synch, matching, reconcile) is ported. M2.11 and M2.12 close out the M2 milestone by wiring two CLI subcommands to those helpers.
Both subcommands today are reported as "not implemented" by the dispatcher in go/cmd/fuj/main.go:32-34. After this change:
fuj feeswill composedomain/feeswith a (stubbed) attendance loader and a fees-table formatter.fuj reconcilewill composedomain/reconcilewith stubbed transaction + exception + attendance loaders and a balance-report formatter.- Both will exit with a clean, actionable error message until M4 wires real Google Sheets IO behind the loader interfaces.
The user asked to do M2.11 and M2.12 together because they share the same loader scaffolding and formatter package — splitting them would either commit half the package or duplicate work.
Approach
One commit, one branch, one MR. Branch: feat/m2-11-12-fees-reconcile-cli. Both M2.11 and M2.12 checkboxes get ticked on merge.
The CLI subcommand is the user-facing layer. It owns nothing. All work lives in a new services/membership package: a) loader interfaces, b) the stub implementations that fail with a clear error, c) the orchestration functions, and d) the text formatters. M4 will later add real loader implementations behind the same interfaces — no other code needs to change.
Package layout
| Path | Contents |
|---|---|
go/internal/services/membership/doc.go |
Package doc: orchestrates domain/fees + domain/reconcile against pluggable IO loaders. |
go/internal/services/membership/loader.go |
AttendanceLoader, TransactionLoader, ExceptionLoader interfaces. Aggregate Sources interface. ErrIOPending sentinel. NewStubSources() factory returning a struct that satisfies all three with ErrIOPending. |
go/internal/services/membership/fees.go |
FeesReport(ctx, AttendanceLoader, io.Writer) error — loads adults, formats, writes. |
go/internal/services/membership/reconcile.go |
ReconcileReport(ctx, Sources, defaultYear int, io.Writer) error — loads adults + txns + exceptions, calls reconcile.Reconcile, formats, writes. |
go/internal/services/membership/format_fees.go |
printFeesTable(w, members, sortedMonths) — fixed-width table mirroring calculate_fees.py:9-49. |
go/internal/services/membership/format_reconcile.go |
printReconcileReport(w, result, sortedMonths) — header / summary table / credits / debts / unmatched / matched-tx detail mirroring match_payments.py:521-640. |
go/internal/services/membership/*_test.go |
Table tests for both formatters using fixture data (no IO); separate test confirms NewStubSources() returns ErrIOPending from each method. |
go/cmd/fuj/main.go |
Add feesCmd(args) and reconcileCmd(args). Drop "fees" and "reconcile" from the not-implemented case (leave "sync", "infer"). |
Public API
// loader.go
type AttendanceLoader interface {
LoadAdults(ctx context.Context) (members []reconcile.Member, sortedMonths []string, err error)
}
type TransactionLoader interface {
LoadTransactions(ctx context.Context) ([]reconcile.Transaction, error)
}
type ExceptionLoader interface {
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
}
// Sources is the aggregate that fuj reconcile needs.
type Sources interface {
AttendanceLoader
TransactionLoader
ExceptionLoader
}
// ErrIOPending is returned by every stub loader method.
var ErrIOPending = errors.New(
"io layer not yet wired up; lands in milestone M4 (sheets/drive/fio)")
func NewStubSources() Sources
// fees.go
func FeesReport(ctx context.Context, l AttendanceLoader, out io.Writer) error
// reconcile.go
func ReconcileReport(ctx context.Context, s Sources, defaultYear int, out io.Writer) error
Loader return types
The interfaces return domain/reconcile types directly (reconcile.Member, reconcile.Transaction, reconcile.ExceptionKey, reconcile.Exception). These are already shaped for the reconcile algorithm and cover the fees report's needs (Member.Fees[month].Expected is precomputed by whatever loader populates it — the Python get_members_with_fees() does the same). No translation layer needed; no parallel struct hierarchy.
Stub implementation pattern
type stubSources struct{}
func (stubSources) LoadAdults(context.Context) ([]reconcile.Member, []string, error) {
return nil, nil, ErrIOPending
}
func (stubSources) LoadTransactions(context.Context) ([]reconcile.Transaction, error) {
return nil, ErrIOPending
}
func (stubSources) LoadExceptions(context.Context) (
map[reconcile.ExceptionKey]reconcile.Exception, error) {
return nil, ErrIOPending
}
func NewStubSources() Sources { return stubSources{} }
Subcommand wiring
// cmd/fuj/main.go
case "fees":
feesCmd(args)
case "reconcile":
reconcileCmd(args)
case "sync", "infer": // keep these in the M4 placeholder
fmt.Fprintf(os.Stderr, "fuj %s: not implemented yet (lands in M4)\n", cmd)
os.Exit(2)
func feesCmd(args []string) {
fs := flag.NewFlagSet("fees", flag.ExitOnError)
fs.Usage = func() { fmt.Fprintln(os.Stderr, "usage: fuj fees") }
if err := fs.Parse(args); err != nil { ... }
sources := membership.NewStubSources()
ctx := context.Background()
if err := membership.FeesReport(ctx, sources, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "fuj fees: %v\n", err)
os.Exit(1)
}
}
func reconcileCmd(args []string) {
fs := flag.NewFlagSet("reconcile", flag.ExitOnError)
fs.Usage = func() { fmt.Fprintln(os.Stderr, "usage: fuj reconcile") }
if err := fs.Parse(args); err != nil { ... }
sources := membership.NewStubSources()
ctx := context.Background()
if err := membership.ReconcileReport(ctx, sources, time.Now().Year(), os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "fuj reconcile: %v\n", err)
os.Exit(1)
}
}
Both subcommands accept zero positional args today (matching Python calculate_fees.py which has none, and skipping the Python match_payments.py flags --sheet-id / --credentials / --bank until M4 needs them — no point pre-empting flag design while there's nothing to plumb them into). Update usage() in main.go to remove the [M2] annotations from fees / reconcile.
Formatter ports — what to mirror byte-for-byte
The formatters are pure post-processing. Ship them now so M4 only adds real loader plumbing.
printFeesTable ports calculate_fees.py:9-49:
- Filter to
tier == "A"only. - Month label format:
"Jan 2026"(time.Parse("2006-01", m).Format("Jan 2006")). - Column widths:
name_width = max(len(name)),col_width = 15. - Cell format:
"%d CZK (%d)"whencount > 0, else"-". Right-aligned in col_width. - Totals row: monthly sums, label
"TOTAL", cells"%d CZK". - Print "No data." when members is empty.
printReconcileReport ports match_payments.py:521-640:
- Header banner (
"=" * 80, "PAYMENT RECONCILIATION REPORT", banner). - Per-adult summary table — cell logic:
expected==0 && paid==0 → "-",paid>=expected && expected>0 → "OK",paid>0 → "{paid}/{expected}", else"UNPAID {expected}". Balance column:"+N"/"-N"/"0". - TOTAL footer line carrying the
Expected/Paid/Balancesummary. - Optional sections:
TOTAL CREDITS(positive total balances),TOTAL DEBTS(negative — printabs),UNMATCHED TRANSACTIONS,MATCHED TRANSACTION DETAILS. - Use sorted member-name iteration (
sort.Strings) — Python usessorted(adults.keys()). - Float printing: amounts use
%.0f(Python:.0f),paidis cast viaint(...)before formatting in some places — preserve these (cast tointthen%d).
Tests use a small handcrafted reconcile.Result with one paid member, one debtor, one credit, one unmatched tx and assert exact byte equality of the formatted output (golden string in the test source — not file-based).
Tests
format_fees_test.go: handcrafted []reconcile.Member covering: tier-filter (J/X excluded), zero-attendance cell, single-attendance cell, multi-attendance cell, empty result → "No data.". Golden output strings inline.
format_reconcile_test.go: handcrafted reconcile.Result exercising every branch in the cell logic + each optional section. Golden strings inline. (Don't blindly copy the Python print(...) string-formatting bugs — the live Python f-string f"\n{'TOTAL CREDITS (advance payments or surplus):'}" is intentional whitespace; reproduce as plain "\nTOTAL CREDITS (advance payments or surplus):" and verify identical bytes by running the live Python on the same fixture.)
stub_test.go: assert each NewStubSources() method returns ErrIOPending (use errors.Is).
fees_test.go / reconcile_test.go: pass a fake loader that returns canned []reconcile.Member / []reconcile.Transaction / exceptions; assert FeesReport / ReconcileReport write the expected formatter output. This proves the orchestration glues correctly without involving stubs.
Verify formatter golden strings against live Python with one-liner comments at top of each test file, e.g.:
PYTHONPATH=scripts:. python -c '
from match_payments import print_report
result = {"members": {...}, "unmatched": [...]}
print_report(result, ["2026-04"])
'
Parity concerns
czech.Normalizekeeps "%" semantics — exception keys inreconcile.Reconcileuseczech.Normalize(name)andczech.Normalize(period). TheExceptionLoaderstub doesn't return any, so this isn't exercised in M2.11/M2.12 — but real loaders in M4 must also normalize at load time (matching Pythonfetch_exceptions).- Sorted month iteration — formatter must respect the
sortedMonthsargument order, not iterate maps directly (Go map iteration is randomized). - Sorted member iteration — adults sorted by name (
sort.Strings); Python usessorted(adults.keys())which is byte-order. Czech-diacritic names sort by codepoint either way. - Empty unmatched list — Python prints nothing; Go must skip the section header when
len(result.Unmatched) == 0. int(paid)truncation — Pythonint(mdata["paid"])truncates toward zero. Goint(float64)matches. Useint(paid)notmath.Round.- Stub error stable string —
ErrIOPending.Error()text is part of the user-facing CLI contract for the duration of M2.11..M3; tests asserterrors.Is, not the string. Don't change the wording without bumping the changelog. - Default year =
time.Now().Year()—reconcile.ReconcileneedsdefaultYearfor the inference fallback.time.Now().Year()matches the Python implicit default for current-year operation. Tests use a fixed year (2026).
Critical files
- Read for parity — scripts/calculate_fees.py (full), scripts/match_payments.py:521-640, scripts/match_payments.py:647-684 (CLI entry shape).
- Reuse —
domain/reconcile.{Member, Transaction, ExceptionKey, Exception, Result, Reconcile}(reconcile.go),domain/fees.{CalculateFee, AdultFeeMonthlyRate}(fees.go). - Mirror conventions — package layout from go/internal/domain/matching/ (one symbol per file,
*_test.gosiblings, top-of-test live-Python verification comments). - New —
go/internal/services/membership/{doc,loader,fees,reconcile,format_fees,format_reconcile}.go+*_test.go. - Modify — go/cmd/fuj/main.go (add
feesCmd/reconcileCmd, drop"fees"/"reconcile"from the M2 not-implemented case, drop[M2]annotations fromusage()).
Out of scope (explicitly DO NOT touch)
- Real Google Sheets / Drive / Fio loader implementations — M4.1–M4.6.
- Web routes / handlers — M5.
fuj syncandfuj infersubcommands — M4.7/M4.8.- Junior fees report — current Python
make feesonly prints adults; preserve. (get_junior_members_with_feesis consumed by the web frontend, not the CLI.) - Bank-direct mode (
--bankflag in match_payments.py:659) — M4 territory. - Fixture capture (
tests/fixtures/) — M3 milestone.
Verification
cd go && go build ./...— clean build.cd go && go test -race ./internal/services/membership/...— formatter golden strings match, stub returnsErrIOPending, orchestration glues fake loader → formatter correctly.cd go && make go-lint— clean (govet, staticcheck, errcheck, gofumpt, unused).- End-to-end CLI smoke:
make go-build && ./bin/fuj fees→ exits non-zero, stderr contains"io layer not yet wired up; lands in milestone M4"../bin/fuj reconcile→ same shape../bin/fuj help→ no longer says[M2]next tofees/reconcile; still says[M4]next tosync/infer.
- Formatter parity spot-check — pick one fixture (1 adult with 2 months of fees, 1 unmatched tx); run the Python equivalent with the same input; confirm Go output is byte-identical (modulo trailing-whitespace lines —
diff -wif needed, but aim for cleandifffirst). - Append CHANGELOG entry per CLAUDE.md (timestamp via
date "+%Y-%m-%d %H:%M %Z"). - Tick M2.11 and M2.12 in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md with the merge SHA. Update the M2 milestone summary line if M2 is now fully closed.
- Push branch, open MR via
tea pr create --title "feat(go): wire fuj fees + fuj reconcile (M2.11-12)" --base main --head feat/m2-11-12-fees-reconcile-cli, print URL, leave merge to user.