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>
216 lines
14 KiB
Markdown
216 lines
14 KiB
Markdown
# 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.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](../../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.1–M4.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.
|