Files
fuj-management/docs/plans/2026-05-06-1738-go-m2-11-12-fees-reconcile-cli.md
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

14 KiB
Raw Permalink Blame History

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.md per CLAUDE.md plan-location convention.

Context

The Go rewrite (tracked in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) finished M2.1M2.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 fees will compose domain/fees with a (stubbed) attendance loader and a fees-table formatter.
  • fuj reconcile will compose domain/reconcile with 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)" when count > 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/Balance summary.
  • Optional sections: TOTAL CREDITS (positive total balances), TOTAL DEBTS (negative — print abs), UNMATCHED TRANSACTIONS, MATCHED TRANSACTION DETAILS.
  • Use sorted member-name iteration (sort.Strings) — Python uses sorted(adults.keys()).
  • Float printing: amounts use %.0f (Python :.0f), paid is cast via int(...) before formatting in some places — preserve these (cast to int then %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.Normalize keeps "%" semantics — exception keys in reconcile.Reconcile use czech.Normalize(name) and czech.Normalize(period). The ExceptionLoader stub 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 Python fetch_exceptions).
  • Sorted month iteration — formatter must respect the sortedMonths argument order, not iterate maps directly (Go map iteration is randomized).
  • Sorted member iteration — adults sorted by name (sort.Strings); Python uses sorted(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 — Python int(mdata["paid"]) truncates toward zero. Go int(float64) matches. Use int(paid) not math.Round.
  • Stub error stable stringErrIOPending.Error() text is part of the user-facing CLI contract for the duration of M2.11..M3; tests assert errors.Is, not the string. Don't change the wording without bumping the changelog.
  • Default year = time.Now().Year()reconcile.Reconcile needs defaultYear for the inference fallback. time.Now().Year() matches the Python implicit default for current-year operation. Tests use a fixed year (2026).

Critical files

Out of scope (explicitly DO NOT touch)

  • Real Google Sheets / Drive / Fio loader implementations — M4.1M4.6.
  • Web routes / handlers — M5.
  • fuj sync and fuj infer subcommands — M4.7/M4.8.
  • Junior fees report — current Python make fees only prints adults; preserve. (get_junior_members_with_fees is consumed by the web frontend, not the CLI.)
  • Bank-direct mode (--bank flag in match_payments.py:659) — M4 territory.
  • Fixture capture (tests/fixtures/) — M3 milestone.

Verification

  1. cd go && go build ./... — clean build.
  2. cd go && go test -race ./internal/services/membership/... — formatter golden strings match, stub returns ErrIOPending, orchestration glues fake loader → formatter correctly.
  3. cd go && make go-lint — clean (govet, staticcheck, errcheck, gofumpt, unused).
  4. 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 to fees/reconcile; still says [M4] next to sync/infer.
  5. 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 -w if needed, but aim for clean diff first).
  6. Append CHANGELOG entry per CLAUDE.md (timestamp via date "+%Y-%m-%d %H:%M %Z").
  7. 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.
  8. 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.