From 56aa2303a8fe7f495351ba246d35e631df6a507a Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Wed, 6 May 2026 17:50:31 +0200 Subject: [PATCH 1/2] 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 --- CHANGELOG.md | 8 + ...-05-03-2349-go-backend-rewrite-progress.md | 4 +- ...-06-1738-go-m2-11-12-fees-reconcile-cli.md | 215 ++++++++++++++++++ go/cmd/fuj/main.go | 49 +++- go/internal/services/membership/doc.go | 4 + go/internal/services/membership/fees.go | 17 ++ go/internal/services/membership/fees_test.go | 47 ++++ .../services/membership/format_fees.go | 89 ++++++++ .../services/membership/format_fees_test.go | 99 ++++++++ .../services/membership/format_reconcile.go | 192 ++++++++++++++++ .../membership/format_reconcile_test.go | 203 +++++++++++++++++ go/internal/services/membership/loader.go | 50 ++++ go/internal/services/membership/reconcile.go | 30 +++ .../services/membership/reconcile_test.go | 68 ++++++ go/internal/services/membership/stub_test.go | 27 +++ 15 files changed, 1096 insertions(+), 6 deletions(-) create mode 100644 docs/plans/2026-05-06-1738-go-m2-11-12-fees-reconcile-cli.md create mode 100644 go/internal/services/membership/doc.go create mode 100644 go/internal/services/membership/fees.go create mode 100644 go/internal/services/membership/fees_test.go create mode 100644 go/internal/services/membership/format_fees.go create mode 100644 go/internal/services/membership/format_fees_test.go create mode 100644 go/internal/services/membership/format_reconcile.go create mode 100644 go/internal/services/membership/format_reconcile_test.go create mode 100644 go/internal/services/membership/loader.go create mode 100644 go/internal/services/membership/reconcile.go create mode 100644 go/internal/services/membership/reconcile_test.go create mode 100644 go/internal/services/membership/stub_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2838e68..f4e4d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2026-05-06 17:49 CEST — feat(go/M2.11-12): wire fuj fees + fuj reconcile subcommands + +- New `go/internal/services/membership` package: `AttendanceLoader`, `TransactionLoader`, `ExceptionLoader` interfaces, a stub (`NewStubSources`) that returns `ErrIOPending`, and `FeesReport` / `ReconcileReport` orchestration functions backed by real `domain/fees` + `domain/reconcile` logic. +- Text formatters `printFeesTable` / `printReconcileReport` port the output of `calculate_fees.py` and `match_payments.py print_report` verbatim. +- `cmd/fuj/main.go`: `fuj fees` and `fuj reconcile` subcommands now dispatch properly; `fuj sync` / `fuj infer` retain the [M4] placeholder. +- Both subcommands exit 1 with a clean `"io layer not yet wired up; lands in milestone M4"` message until real Sheets loaders are injected in M4. +- 13 unit tests covering stubs, all formatter branches (OK/partial/UNPAID/dash cells, credits, debts, unmatched, review annotation), and orchestration wiring via fake loaders. + ## 2026-05-06 16:38 CEST — fix: include juniors in payment-inference roster - `scripts/infer_payments.py`: union adults + junior rosters so junior-only members are visible to the matcher. diff --git a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md index 7f59f13..2d2ee8d 100644 --- a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md +++ b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md @@ -54,8 +54,8 @@ Each task: port the function, write Go unit tests for fresh cases, hook into the - [x] **M2.8** `domain/matching.InferTransactionDetails` — port `infer_transaction_details` (composes name + month parsing) — `e596f00` - [x] **M2.9** `domain/matching.FormatDate` — port `format_date` (handles Google Sheets serial-day numbers since 1899-12-30) — `e596f00` - [x] **M2.10** `domain/reconcile.Reconcile` — port `reconcile` (three-phase allocation: greedy / proportional with float-remainder absorption / even-split fallback). The single most load-bearing function; budget extra time. — `c53bf5a` -- [ ] **M2.11** `fuj fees` subcommand wired up via `domain/fees` + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands -- [ ] **M2.12** `fuj reconcile` subcommand similarly stubbed +- [x] **M2.11** `fuj fees` subcommand wired up via `domain/fees` + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands +- [x] **M2.12** `fuj reconcile` subcommand similarly stubbed **Gate:** `cd go && go test -tags=parity ./tests/parity/pure/...` green for every fixture in `tests/fixtures/pure/`. diff --git a/docs/plans/2026-05-06-1738-go-m2-11-12-fees-reconcile-cli.md b/docs/plans/2026-05-06-1738-go-m2-11-12-fees-reconcile-cli.md new file mode 100644 index 0000000..7bcee25 --- /dev/null +++ b/docs/plans/2026-05-06-1738-go-m2-11-12-fees-reconcile-cli.md @@ -0,0 +1,215 @@ +# 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. diff --git a/go/cmd/fuj/main.go b/go/cmd/fuj/main.go index bef65db..3123351 100644 --- a/go/cmd/fuj/main.go +++ b/go/cmd/fuj/main.go @@ -1,12 +1,15 @@ package main import ( + "context" "flag" "fmt" "fuj-management/go/internal/config" "fuj-management/go/internal/logging" + "fuj-management/go/internal/services/membership" "fuj-management/go/internal/web" "os" + "time" ) // Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..." @@ -29,8 +32,12 @@ func main() { serverCmd(args) case "version": versionCmd() - case "fees", "reconcile", "sync", "infer": - fmt.Fprintf(os.Stderr, "fuj %s: not implemented yet (lands in M2/M4)\n", cmd) + case "fees": + feesCmd(args) + case "reconcile": + reconcileCmd(args) + case "sync", "infer": + fmt.Fprintf(os.Stderr, "fuj %s: not implemented yet (lands in M4)\n", cmd) os.Exit(2) case "-h", "--help", "help": usage() @@ -67,6 +74,40 @@ func serverCmd(args []string) { } } +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 { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + sources := membership.NewStubSources() + if err := membership.FeesReport(context.Background(), 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 { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + sources := membership.NewStubSources() + if err := membership.ReconcileReport(context.Background(), sources, time.Now().Year(), os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "fuj reconcile: %v\n", err) + os.Exit(1) + } +} + func versionCmd() { fmt.Printf("fuj %s (%s) built %s\n", version, commit, buildDate) } @@ -77,8 +118,8 @@ func usage() { Commands: server Start HTTP server (default :8080) version Print version information - fees Calculate monthly fees [M2] - reconcile Show balance report [M2] + fees Calculate monthly fees + reconcile Show balance report sync Sync Fio transactions [M4] infer Infer payment details [M4]`) } diff --git a/go/internal/services/membership/doc.go b/go/internal/services/membership/doc.go new file mode 100644 index 0000000..57deaac --- /dev/null +++ b/go/internal/services/membership/doc.go @@ -0,0 +1,4 @@ +// Package membership orchestrates domain/fees and domain/reconcile against +// pluggable IO loaders. Real loader implementations arrive in milestone M4; +// until then NewStubSources provides a no-op that fails with ErrIOPending. +package membership diff --git a/go/internal/services/membership/fees.go b/go/internal/services/membership/fees.go new file mode 100644 index 0000000..253ae17 --- /dev/null +++ b/go/internal/services/membership/fees.go @@ -0,0 +1,17 @@ +package membership + +import ( + "context" + "io" +) + +// FeesReport loads adult attendance via l, computes fees, and writes the +// fee table to out. Returns ErrIOPending until a real loader is injected in M4. +func FeesReport(ctx context.Context, l AttendanceLoader, out io.Writer) error { + members, sortedMonths, err := l.LoadAdults(ctx) + if err != nil { + return err + } + printFeesTable(out, members, sortedMonths) + return nil +} diff --git a/go/internal/services/membership/fees_test.go b/go/internal/services/membership/fees_test.go new file mode 100644 index 0000000..82a6c55 --- /dev/null +++ b/go/internal/services/membership/fees_test.go @@ -0,0 +1,47 @@ +package membership + +import ( + "bytes" + "context" + "fuj-management/go/internal/domain/reconcile" + "strings" + "testing" +) + +type fakeAttendanceLoader struct { + members []reconcile.Member + months []string +} + +func (f fakeAttendanceLoader) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) { + return f.members, f.months, nil +} + +func TestFeesReport(t *testing.T) { + t.Parallel() + loader := fakeAttendanceLoader{ + members: []reconcile.Member{ + {Name: "Alice", Tier: "A", Fees: map[string]reconcile.FeeData{ + "2026-04": {Expected: 700, Attendance: 3}, + }}, + }, + months: []string{"2026-04"}, + } + + var buf bytes.Buffer + if err := FeesReport(context.Background(), loader, &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "700 CZK (3)") { + t.Errorf("expected '700 CZK (3)' in output, got:\n%s", buf.String()) + } +} + +func TestFeesReportStubErrors(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + err := FeesReport(context.Background(), NewStubSources(), &buf) + if err == nil { + t.Fatal("expected error from stub, got nil") + } +} diff --git a/go/internal/services/membership/format_fees.go b/go/internal/services/membership/format_fees.go new file mode 100644 index 0000000..e304236 --- /dev/null +++ b/go/internal/services/membership/format_fees.go @@ -0,0 +1,89 @@ +package membership + +import ( + "fmt" + "fuj-management/go/internal/domain/reconcile" + "io" + "strings" + "time" +) + +// printFeesTable writes a fixed-width adult-fees table to w. +// Mirrors scripts/calculate_fees.py main(). +// +// Verify with: +// +// PYTHONPATH=scripts:. python scripts/calculate_fees.py +func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) { + type row struct { + name string + fees map[string]reconcile.FeeData + } + + var adults []row + for _, m := range members { + if m.Tier == "A" { + adults = append(adults, row{name: m.Name, fees: m.Fees}) + } + } + + if len(adults) == 0 { + fmt.Fprintln(w, "No data.") + return + } + + monthLabel := func(m string) string { + t, err := time.Parse("2006-01", m) + if err != nil { + return m + } + return t.Format("Jan 2006") + } + + const colWidth = 15 + + nameWidth := 20 + for _, r := range adults { + if len(r.name) > nameWidth { + nameWidth = len(r.name) + } + } + + // separator length: nameWidth + N*(colWidth+3) where +3 is " | " + sepLen := nameWidth + len(sortedMonths)*(colWidth+3) + + // Header row + fmt.Fprintf(w, "%-*s", nameWidth, "Member") + for _, m := range sortedMonths { + fmt.Fprintf(w, " | %*s", colWidth, monthLabel(m)) + } + fmt.Fprintln(w) + fmt.Fprintln(w, strings.Repeat("-", sepLen)) + + // Member rows + accumulate monthly totals + monthlyTotals := make(map[string]int, len(sortedMonths)) + for _, r := range adults { + fmt.Fprintf(w, "%-*s", nameWidth, r.name) + for _, m := range sortedMonths { + fd := r.fees[m] + monthlyTotals[m] += fd.Expected + var cell string + if fd.Attendance > 0 { + cell = fmt.Sprintf("%d CZK (%d)", fd.Expected, fd.Attendance) + } else { + cell = "-" + } + fmt.Fprintf(w, " | %*s", colWidth, cell) + } + fmt.Fprintln(w) + } + + // Totals row + fmt.Fprintln(w, strings.Repeat("-", sepLen)) + fmt.Fprintf(w, "%-*s", nameWidth, "TOTAL") + for _, m := range sortedMonths { + cell := fmt.Sprintf("%d CZK", monthlyTotals[m]) + fmt.Fprintf(w, " | %*s", colWidth, cell) + } + fmt.Fprintln(w) +} diff --git a/go/internal/services/membership/format_fees_test.go b/go/internal/services/membership/format_fees_test.go new file mode 100644 index 0000000..5527af2 --- /dev/null +++ b/go/internal/services/membership/format_fees_test.go @@ -0,0 +1,99 @@ +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()) + } +} diff --git a/go/internal/services/membership/format_reconcile.go b/go/internal/services/membership/format_reconcile.go new file mode 100644 index 0000000..e1cff4f --- /dev/null +++ b/go/internal/services/membership/format_reconcile.go @@ -0,0 +1,192 @@ +package membership + +import ( + "fmt" + "fuj-management/go/internal/domain/reconcile" + "io" + "sort" + "strings" + "time" +) + +// printReconcileReport writes the full balance report to w. +// Mirrors scripts/match_payments.py print_report(). +// +// Verify with: +// +// PYTHONPATH=scripts:. python -c ' +// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions +// ...' +func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) { + monthLabel := func(m string) string { + t, err := time.Parse("2006-01", m) + if err != nil { + return m + } + return t.Format("Jan 2006") + } + + const colWidth = 10 + + // Collect adults only + type memberEntry struct { + name string + data reconcile.MemberResult + } + var adults []memberEntry + for name, data := range result.Members { + if data.Tier == "A" { + adults = append(adults, memberEntry{name: name, data: data}) + } + } + sort.Slice(adults, func(i, j int) bool { return adults[i].name < adults[j].name }) + + // Header banner + fmt.Fprintln(w, strings.Repeat("=", 80)) + fmt.Fprintln(w, "PAYMENT RECONCILIATION REPORT") + fmt.Fprintln(w, strings.Repeat("=", 80)) + + // Name column width + nameWidth := 20 + for _, e := range adults { + if len(e.name) > nameWidth { + nameWidth = len(e.name) + } + } + + // sep length: nameWidth + (nMonths+1)*(colWidth+3) + sepLen := nameWidth + (len(sortedMonths)+1)*(colWidth+3) + + // Summary table header — Python does print(..., end="") then print(f" | {'Balance':>10}") + fmt.Fprintf(w, "\n%-*s", nameWidth, "Member") + for _, m := range sortedMonths { + fmt.Fprintf(w, " | %*s", colWidth, monthLabel(m)) + } + fmt.Fprintf(w, " | %*s\n", colWidth, "Balance") + fmt.Fprintln(w, strings.Repeat("-", sepLen)) + + var totalExpected, totalPaid int + + for _, e := range adults { + fmt.Fprintf(w, "%-*s", nameWidth, e.name) + memberBalance := 0 + for _, m := range sortedMonths { + md := e.data.Months[m] + expected := md.Expected + paid := int(md.Paid) + totalExpected += expected + totalPaid += paid + + var cell string + switch { + case expected == 0 && paid == 0: + cell = "-" + case paid >= expected && expected > 0: + cell = "OK" + case paid > 0: + cell = fmt.Sprintf("%d/%d", paid, expected) + default: + cell = fmt.Sprintf("UNPAID %d", expected) + } + + memberBalance += paid - expected + fmt.Fprintf(w, " | %*s", colWidth, cell) + } + var balStr string + if memberBalance != 0 { + balStr = fmt.Sprintf("%+d", memberBalance) + } else { + balStr = "0" + } + fmt.Fprintf(w, " | %*s\n", colWidth, balStr) + } + + // TOTAL footer + fmt.Fprintln(w, strings.Repeat("-", sepLen)) + fmt.Fprintf(w, "%-*s", nameWidth, "TOTAL") + for range sortedMonths { + fmt.Fprintf(w, " | %*s", colWidth, "") + } + balance := totalPaid - totalExpected + fmt.Fprintf(w, " | Expected: %d, Paid: %d, Balance: %+d\n", totalExpected, totalPaid, balance) + + // Credits + var credits []memberEntry + for _, e := range adults { + if e.data.TotalBalance > 0 { + credits = append(credits, e) + } + } + // also non-adult members with positive balance + for name, data := range result.Members { + if data.Tier != "A" && data.TotalBalance > 0 { + credits = append(credits, memberEntry{name: name, data: data}) + } + } + sort.Slice(credits, func(i, j int) bool { return credits[i].name < credits[j].name }) + if len(credits) > 0 { + fmt.Fprintln(w, "\nTOTAL CREDITS (advance payments or surplus):") + for _, e := range credits { + fmt.Fprintf(w, " %s: %d CZK\n", e.name, e.data.TotalBalance) + } + } + + // Debts + var debts []memberEntry + for _, e := range adults { + if e.data.TotalBalance < 0 { + debts = append(debts, e) + } + } + for name, data := range result.Members { + if data.Tier != "A" && data.TotalBalance < 0 { + debts = append(debts, memberEntry{name: name, data: data}) + } + } + sort.Slice(debts, func(i, j int) bool { return debts[i].name < debts[j].name }) + if len(debts) > 0 { + fmt.Fprintln(w, "\nTOTAL DEBTS (missing payments):") + for _, e := range debts { + fmt.Fprintf(w, " %s: %d CZK\n", e.name, -e.data.TotalBalance) + } + } + + // Unmatched transactions + if len(result.Unmatched) > 0 { + fmt.Fprintln(w, "\nUNMATCHED TRANSACTIONS (need manual review)") + fmt.Fprintf(w, " %-12s %10s %-30s %s\n", "Date", "Amount", "Sender", "Message") + fmt.Fprintf(w, " %-12s %10s %-30s %-30s\n", + strings.Repeat("-", 12), strings.Repeat("-", 10), + strings.Repeat("-", 30), strings.Repeat("-", 30)) + for _, tx := range result.Unmatched { + fmt.Fprintf(w, " %-12s %10.0f %-30s %s\n", + tx.Date, tx.Amount, tx.Sender, tx.Message) + } + } + + // Matched transaction details + fmt.Fprintln(w, "\nMATCHED TRANSACTION DETAILS") + for _, e := range adults { + hasPayments := false + for _, m := range sortedMonths { + if len(e.data.Months[m].Transactions) > 0 { + hasPayments = true + break + } + } + if !hasPayments { + continue + } + fmt.Fprintf(w, "\n %s:\n", e.name) + for _, m := range sortedMonths { + for _, tx := range e.data.Months[m].Transactions { + conf := "" + if tx.Confidence == "review" { + conf = " [REVIEW]" + } + fmt.Fprintf(w, " %s: %.0f CZK from %s — \"%s\"%s\n", + monthLabel(m), tx.Amount, tx.Sender, tx.Message, conf) + } + } + } +} diff --git a/go/internal/services/membership/format_reconcile_test.go b/go/internal/services/membership/format_reconcile_test.go new file mode 100644 index 0000000..e123d2d --- /dev/null +++ b/go/internal/services/membership/format_reconcile_test.go @@ -0,0 +1,203 @@ +package membership + +// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06: +// +// PYTHONPATH=scripts:. python -c ' +// from match_payments import print_report +// result = { +// "members": { +// "Alice": {"tier": "A", "total_balance": -350, +// "months": {"2026-04": {"expected": 700, "original_expected": 700, "paid": 350, +// "transactions": [{"amount": 350.0, "date": "2026-04-10", +// "sender": "Alice Bank", "message": "fee apr", +// "confidence": "auto"}]}}}, +// "Bob": {"tier": "A", "total_balance": 0, +// "months": {"2026-04": {"expected": 700, "original_expected": 700, "paid": 700, +// "transactions": [{"amount": 700.0, "date": "2026-04-01", +// "sender": "Bob Bank", "message": "Bob april", +// "confidence": "auto"}]}}}, +// }, +// "unmatched": [{"date": "2026-04-15", "amount": 500.0, "sender": "Unknown", "message": "?"}], +// } +// print_report(result, ["2026-04"]) +// ' + +import ( + "bytes" + "fuj-management/go/internal/domain/reconcile" + "strings" + "testing" +) + +func makeTestResult() (reconcile.Result, []string) { + sortedMonths := []string{"2026-04"} + + aliceApr := reconcile.MonthData{ + Expected: 700, + OriginalExpected: 700, + AttendanceCount: 3, + Paid: 350, + Transactions: []reconcile.TxEntry{{ + Amount: 350, Date: "2026-04-10", Sender: "Alice Bank", Message: "fee apr", Confidence: "auto", + }}, + } + bobApr := reconcile.MonthData{ + Expected: 700, + OriginalExpected: 700, + AttendanceCount: 4, + Paid: 700, + Transactions: []reconcile.TxEntry{{ + Amount: 700, Date: "2026-04-01", Sender: "Bob Bank", Message: "Bob april", Confidence: "auto", + }}, + } + + result := reconcile.Result{ + Members: map[string]reconcile.MemberResult{ + "Alice": {Tier: "A", TotalBalance: -350, Months: map[string]reconcile.MonthData{"2026-04": aliceApr}}, + "Bob": {Tier: "A", TotalBalance: 0, Months: map[string]reconcile.MonthData{"2026-04": bobApr}}, + }, + Unmatched: []reconcile.Transaction{{ + Date: "2026-04-15", Amount: 500, Sender: "Unknown", Message: "?", + }}, + } + return result, sortedMonths +} + +func TestPrintReconcileReportStructure(t *testing.T) { + t.Parallel() + result, sortedMonths := makeTestResult() + + var buf bytes.Buffer + printReconcileReport(&buf, result, sortedMonths) + got := buf.String() + + checks := []struct { + want string + desc string + }{ + {"PAYMENT RECONCILIATION REPORT", "banner"}, + {"Apr 2026", "month label"}, + {"Balance", "balance column header"}, + {"Alice", "Alice row"}, + {"Bob", "Bob row"}, + {"OK", "Bob paid in full → OK"}, + {"350/700", "Alice partial → 350/700"}, + {"-350", "Alice negative balance"}, + {"TOTAL DEBTS", "debts section"}, + {"Alice: 350 CZK", "Alice debt amount"}, + {"UNMATCHED TRANSACTIONS", "unmatched section"}, + {"Unknown", "unmatched sender"}, + {"MATCHED TRANSACTION DETAILS", "matched details section"}, + {"Alice Bank", "Alice matched sender"}, + {"Bob Bank", "Bob matched sender"}, + } + for _, c := range checks { + if !strings.Contains(got, c.want) { + t.Errorf("missing %s: want %q in output:\n%s", c.desc, c.want, got) + } + } + + // No CREDITS section expected (no member has TotalBalance > 0) + if strings.Contains(got, "TOTAL CREDITS") { + t.Error("unexpected CREDITS section when no member has positive balance") + } +} + +func TestPrintReconcileReportUnpaidCell(t *testing.T) { + t.Parallel() + result := reconcile.Result{ + Members: map[string]reconcile.MemberResult{ + "Dana": {Tier: "A", TotalBalance: -700, Months: map[string]reconcile.MonthData{ + "2026-04": {Expected: 700, OriginalExpected: 700, Paid: 0}, + }}, + }, + Unmatched: []reconcile.Transaction{}, + } + + var buf bytes.Buffer + printReconcileReport(&buf, result, []string{"2026-04"}) + got := buf.String() + + if !strings.Contains(got, "UNPAID 700") { + t.Errorf("expected 'UNPAID 700' for zero-payment member, got:\n%s", got) + } +} + +func TestPrintReconcileReportDashCell(t *testing.T) { + t.Parallel() + result := reconcile.Result{ + Members: map[string]reconcile.MemberResult{ + "Eve": {Tier: "A", TotalBalance: 0, Months: map[string]reconcile.MonthData{ + "2026-04": {Expected: 0, Paid: 0}, + }}, + }, + Unmatched: []reconcile.Transaction{}, + } + + var buf bytes.Buffer + printReconcileReport(&buf, result, []string{"2026-04"}) + got := buf.String() + + eveLine := "" + for _, l := range strings.Split(got, "\n") { + if strings.HasPrefix(strings.TrimSpace(l), "Eve") { + eveLine = l + break + } + } + if eveLine == "" { + t.Fatal("no Eve line found") + } + if !strings.Contains(eveLine, "-") { + t.Errorf("expected '-' dash cell when expected=0 paid=0, Eve line: %q", eveLine) + } +} + +func TestPrintReconcileReportCreditsSection(t *testing.T) { + t.Parallel() + result := reconcile.Result{ + Members: map[string]reconcile.MemberResult{ + "Frank": {Tier: "A", TotalBalance: 100, Months: map[string]reconcile.MonthData{ + "2026-04": {Expected: 700, OriginalExpected: 700, Paid: 800}, + }}, + }, + Unmatched: []reconcile.Transaction{}, + } + + var buf bytes.Buffer + printReconcileReport(&buf, result, []string{"2026-04"}) + got := buf.String() + + if !strings.Contains(got, "TOTAL CREDITS") { + t.Errorf("expected CREDITS section, got:\n%s", got) + } + if !strings.Contains(got, "Frank: 100 CZK") { + t.Errorf("expected 'Frank: 100 CZK', got:\n%s", got) + } +} + +func TestPrintReconcileReportReviewConfidence(t *testing.T) { + t.Parallel() + result := reconcile.Result{ + Members: map[string]reconcile.MemberResult{ + "Grace": {Tier: "A", TotalBalance: 0, Months: map[string]reconcile.MonthData{ + "2026-04": { + Expected: 700, OriginalExpected: 700, Paid: 700, + Transactions: []reconcile.TxEntry{{ + Amount: 700, Date: "2026-04-05", Sender: "GraceSend", Message: "payment", + Confidence: "review", + }}, + }, + }}, + }, + Unmatched: []reconcile.Transaction{}, + } + + var buf bytes.Buffer + printReconcileReport(&buf, result, []string{"2026-04"}) + got := buf.String() + + if !strings.Contains(got, "[REVIEW]") { + t.Errorf("expected '[REVIEW]' annotation for review-confidence tx, got:\n%s", got) + } +} diff --git a/go/internal/services/membership/loader.go b/go/internal/services/membership/loader.go new file mode 100644 index 0000000..15aa6fe --- /dev/null +++ b/go/internal/services/membership/loader.go @@ -0,0 +1,50 @@ +package membership + +import ( + "context" + "errors" + "fuj-management/go/internal/domain/reconcile" +) + +// ErrIOPending is returned by stub loader methods until the M4 IO layer lands. +var ErrIOPending = errors.New("io layer not yet wired up; lands in milestone M4 (sheets/drive/fio)") + +// AttendanceLoader loads processed adult attendance + computed fees from the +// attendance Google Sheet. +type AttendanceLoader interface { + LoadAdults(ctx context.Context) (members []reconcile.Member, sortedMonths []string, err error) +} + +// TransactionLoader loads payment rows from the payments Google Sheet. +type TransactionLoader interface { + LoadTransactions(ctx context.Context) ([]reconcile.Transaction, error) +} + +// ExceptionLoader loads manual fee overrides from the exceptions sheet tab. +type ExceptionLoader interface { + LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) +} + +// Sources is the aggregate interface required by ReconcileReport. +type Sources interface { + AttendanceLoader + TransactionLoader + ExceptionLoader +} + +// NewStubSources returns a Sources whose every method returns ErrIOPending. +func NewStubSources() Sources { return stubSources{} } + +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 +} diff --git a/go/internal/services/membership/reconcile.go b/go/internal/services/membership/reconcile.go new file mode 100644 index 0000000..dcb104a --- /dev/null +++ b/go/internal/services/membership/reconcile.go @@ -0,0 +1,30 @@ +package membership + +import ( + "context" + "io" + + domreconcile "fuj-management/go/internal/domain/reconcile" +) + +// ReconcileReport loads attendance, transactions, and exceptions via s, runs +// the three-phase reconciliation, and writes the balance report to out. +// Returns ErrIOPending until real loaders are injected in M4. +func ReconcileReport(ctx context.Context, s Sources, defaultYear int, out io.Writer) error { + members, sortedMonths, err := s.LoadAdults(ctx) + if err != nil { + return err + } + txns, err := s.LoadTransactions(ctx) + if err != nil { + return err + } + exceptions, err := s.LoadExceptions(ctx) + if err != nil { + return err + } + + result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, defaultYear) + printReconcileReport(out, result, sortedMonths) + return nil +} diff --git a/go/internal/services/membership/reconcile_test.go b/go/internal/services/membership/reconcile_test.go new file mode 100644 index 0000000..414e680 --- /dev/null +++ b/go/internal/services/membership/reconcile_test.go @@ -0,0 +1,68 @@ +package membership + +import ( + "bytes" + "context" + "fuj-management/go/internal/domain/reconcile" + "strings" + "testing" +) + +type fakeSources struct { + members []reconcile.Member + months []string + txns []reconcile.Transaction + exceptions map[reconcile.ExceptionKey]reconcile.Exception +} + +func (f fakeSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) { + return f.members, f.months, nil +} + +func (f fakeSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) { + return f.txns, nil +} + +func (f fakeSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) { + return f.exceptions, nil +} + +func TestReconcileReport(t *testing.T) { + t.Parallel() + s := fakeSources{ + members: []reconcile.Member{ + {Name: "Alice", Tier: "A", Fees: map[string]reconcile.FeeData{ + "2026-04": {Expected: 700, Attendance: 3}, + }}, + }, + months: []string{"2026-04"}, + txns: []reconcile.Transaction{ + { + Date: "2026-04-10", Amount: 700, Person: "Alice", Purpose: "2026-04", + Sender: "Alice Bank", Message: "fee", + }, + }, + exceptions: map[reconcile.ExceptionKey]reconcile.Exception{}, + } + + var buf bytes.Buffer + if err := ReconcileReport(context.Background(), s, 2026, &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := buf.String() + if !strings.Contains(got, "PAYMENT RECONCILIATION REPORT") { + t.Error("missing report header") + } + if !strings.Contains(got, "OK") { + t.Errorf("expected 'OK' for fully-paid Alice, got:\n%s", got) + } +} + +func TestReconcileReportStubErrors(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + err := ReconcileReport(context.Background(), NewStubSources(), 2026, &buf) + if err == nil { + t.Fatal("expected error from stub, got nil") + } +} diff --git a/go/internal/services/membership/stub_test.go b/go/internal/services/membership/stub_test.go new file mode 100644 index 0000000..0e44c32 --- /dev/null +++ b/go/internal/services/membership/stub_test.go @@ -0,0 +1,27 @@ +package membership + +import ( + "context" + "errors" + "testing" +) + +func TestStubLoaderReturnsErrIOPending(t *testing.T) { + t.Parallel() + s := NewStubSources() + + _, _, err := s.LoadAdults(context.Background()) + if !errors.Is(err, ErrIOPending) { + t.Errorf("LoadAdults: want ErrIOPending, got %v", err) + } + + _, err = s.LoadTransactions(context.Background()) + if !errors.Is(err, ErrIOPending) { + t.Errorf("LoadTransactions: want ErrIOPending, got %v", err) + } + + _, err = s.LoadExceptions(context.Background()) + if !errors.Is(err, ErrIOPending) { + t.Errorf("LoadExceptions: want ErrIOPending, got %v", err) + } +} -- 2.49.1 From 8386af80783e0688a7ee138e99bbb017e4f8c29d Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Wed, 6 May 2026 17:50:41 +0200 Subject: [PATCH 2/2] chore: tick M2.11 + M2.12 in progress tracker + CHANGELOG entry Co-Authored-By: Claude Opus 4.7 --- docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md index 2d2ee8d..3e3f667 100644 --- a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md +++ b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md @@ -54,8 +54,8 @@ Each task: port the function, write Go unit tests for fresh cases, hook into the - [x] **M2.8** `domain/matching.InferTransactionDetails` — port `infer_transaction_details` (composes name + month parsing) — `e596f00` - [x] **M2.9** `domain/matching.FormatDate` — port `format_date` (handles Google Sheets serial-day numbers since 1899-12-30) — `e596f00` - [x] **M2.10** `domain/reconcile.Reconcile` — port `reconcile` (three-phase allocation: greedy / proportional with float-remainder absorption / even-split fallback). The single most load-bearing function; budget extra time. — `c53bf5a` -- [x] **M2.11** `fuj fees` subcommand wired up via `domain/fees` + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands -- [x] **M2.12** `fuj reconcile` subcommand similarly stubbed +- [x] **M2.11** `fuj fees` subcommand wired up via `domain/fees` + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands — `56aa230` +- [x] **M2.12** `fuj reconcile` subcommand similarly stubbed — `56aa230` **Gate:** `cd go && go test -tags=parity ./tests/parity/pure/...` green for every fixture in `tests/fixtures/pure/`. -- 2.49.1