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

216 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../../CLAUDE.md) plan-location convention.
## Context
The Go rewrite (tracked in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](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](../../go/cmd/fuj/main.go#L32). 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](../../scripts/calculate_fees.py#L9). |
| `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](../../scripts/match_payments.py#L521). |
| `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
```go
// 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
```go
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
```go
// 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](../../scripts/calculate_fees.py#L9):
- 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](../../scripts/match_payments.py#L521):
- 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 string** — `ErrIOPending.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
- **Read for parity** — [scripts/calculate_fees.py](../../scripts/calculate_fees.py) (full), [scripts/match_payments.py:521-640](../../scripts/match_payments.py#L521), [scripts/match_payments.py:647-684](../../scripts/match_payments.py#L647) (CLI entry shape).
- **Reuse** — `domain/reconcile.{Member, Transaction, ExceptionKey, Exception, Result, Reconcile}` ([reconcile.go](../../go/internal/domain/reconcile/reconcile.go)), `domain/fees.{CalculateFee, AdultFeeMonthlyRate}` ([fees.go](../../go/internal/domain/fees/fees.go)).
- **Mirror conventions** — package layout from [go/internal/domain/matching/](../../go/internal/domain/matching/) (one symbol per file, `*_test.go` siblings, 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](../../go/cmd/fuj/main.go) (add `feesCmd`/`reconcileCmd`, drop `"fees"`/`"reconcile"` from the M2 not-implemented case, drop `[M2]` annotations from `usage()`).
## 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](../../scripts/match_payments.py#L659)) — 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](../../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](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.