Compare commits
5 Commits
c5a8a4e7b1
...
feat/m2-11
| Author | SHA1 | Date | |
|---|---|---|---|
| 8386af8078 | |||
| 56aa2303a8 | |||
| ea8622a541 | |||
| 71278e6f7a | |||
| 34ce0be5a0 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,10 +1,22 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- `scripts/infer_payments.py`: union adults + junior rosters so junior-only members are visible to the matcher.
|
||||||
- Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Kubík were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review.
|
- Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Kubík were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review.
|
||||||
- Two regression tests added to `tests/test_match_members.py`.
|
- Two regression tests added to `tests/test_match_members.py`.
|
||||||
|
## 2026-05-06 16:05 CEST — feat(go/M2.10): port domain/reconcile.Reconcile
|
||||||
|
|
||||||
|
- New `go/internal/domain/reconcile` package porting the three-phase payment allocation from `scripts/match_payments.py reconcile()`.
|
||||||
|
- 12 unit tests covering all Python test cases plus Go-only extras (diacritics tolerance, `[?]` stripping, `other:` purpose, out-of-window credit, inference fallback, unmatched, no-transaction guard).
|
||||||
|
|
||||||
## 2026-05-06 13:18 CEST — feat(go/M2.7-2.9): port domain/matching package
|
## 2026-05-06 13:18 CEST — feat(go/M2.7-2.9): port domain/matching package
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ Each task: port the function, write Go unit tests for fresh cases, hook into the
|
|||||||
- [x] **M2.7** `domain/matching.BuildNameVariants` + `MatchMembers` — port `_build_name_variants` and `match_members` from [match_payments.py](scripts/match_payments.py) (auto vs review confidence, common-surname filter) — `e596f00`
|
- [x] **M2.7** `domain/matching.BuildNameVariants` + `MatchMembers` — port `_build_name_variants` and `match_members` from [match_payments.py](scripts/match_payments.py) (auto vs review confidence, common-surname filter) — `e596f00`
|
||||||
- [x] **M2.8** `domain/matching.InferTransactionDetails` — port `infer_transaction_details` (composes name + month parsing) — `e596f00`
|
- [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.9** `domain/matching.FormatDate` — port `format_date` (handles Google Sheets serial-day numbers since 1899-12-30) — `e596f00`
|
||||||
- [ ] **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.
|
- [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
|
- [x] **M2.11** `fuj fees` subcommand wired up via `domain/fees` + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands — `56aa230`
|
||||||
- [ ] **M2.12** `fuj reconcile` subcommand similarly stubbed
|
- [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/`.
|
**Gate:** `cd go && go test -tags=parity ./tests/parity/pure/...` green for every fixture in `tests/fixtures/pure/`.
|
||||||
|
|
||||||
|
|||||||
215
docs/plans/2026-05-06-1738-go-m2-11-12-fees-reconcile-cli.md
Normal file
215
docs/plans/2026-05-06-1738-go-m2-11-12-fees-reconcile-cli.md
Normal file
@@ -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.
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/config"
|
"fuj-management/go/internal/config"
|
||||||
"fuj-management/go/internal/logging"
|
"fuj-management/go/internal/logging"
|
||||||
|
"fuj-management/go/internal/services/membership"
|
||||||
"fuj-management/go/internal/web"
|
"fuj-management/go/internal/web"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
|
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
|
||||||
@@ -29,8 +32,12 @@ func main() {
|
|||||||
serverCmd(args)
|
serverCmd(args)
|
||||||
case "version":
|
case "version":
|
||||||
versionCmd()
|
versionCmd()
|
||||||
case "fees", "reconcile", "sync", "infer":
|
case "fees":
|
||||||
fmt.Fprintf(os.Stderr, "fuj %s: not implemented yet (lands in M2/M4)\n", cmd)
|
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)
|
os.Exit(2)
|
||||||
case "-h", "--help", "help":
|
case "-h", "--help", "help":
|
||||||
usage()
|
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() {
|
func versionCmd() {
|
||||||
fmt.Printf("fuj %s (%s) built %s\n", version, commit, buildDate)
|
fmt.Printf("fuj %s (%s) built %s\n", version, commit, buildDate)
|
||||||
}
|
}
|
||||||
@@ -77,8 +118,8 @@ func usage() {
|
|||||||
Commands:
|
Commands:
|
||||||
server Start HTTP server (default :8080)
|
server Start HTTP server (default :8080)
|
||||||
version Print version information
|
version Print version information
|
||||||
fees Calculate monthly fees [M2]
|
fees Calculate monthly fees
|
||||||
reconcile Show balance report [M2]
|
reconcile Show balance report
|
||||||
sync Sync Fio transactions [M4]
|
sync Sync Fio transactions [M4]
|
||||||
infer Infer payment details [M4]`)
|
infer Infer payment details [M4]`)
|
||||||
}
|
}
|
||||||
|
|||||||
393
go/internal/domain/reconcile/reconcile.go
Normal file
393
go/internal/domain/reconcile/reconcile.go
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py.
|
||||||
|
package reconcile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fuj-management/go/internal/domain/czech"
|
||||||
|
"fuj-management/go/internal/domain/matching"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExceptionKey identifies a fee override by normalized member name and period.
|
||||||
|
type ExceptionKey struct {
|
||||||
|
Name string // czech.Normalize(memberName)
|
||||||
|
Period string // czech.Normalize("YYYY-MM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exception is a manual fee override for one member in one period.
|
||||||
|
type Exception struct {
|
||||||
|
Amount int
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeeData holds the expected fee and attendance count for one member in one month.
|
||||||
|
type FeeData struct {
|
||||||
|
Expected int
|
||||||
|
Attendance int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member is one row from the attendance sheet.
|
||||||
|
type Member struct {
|
||||||
|
Name string
|
||||||
|
Tier string
|
||||||
|
Fees map[string]FeeData // month ("YYYY-MM") → fee data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction is one payment row from the payments sheet.
|
||||||
|
// Date must already be a "YYYY-MM-DD" string (convert with matching.FormatDate before calling).
|
||||||
|
// InferredAmount, when non-nil, replaces Amount when person and purpose are pre-matched.
|
||||||
|
type Transaction struct {
|
||||||
|
Date string
|
||||||
|
Amount float64
|
||||||
|
Person string // comma-separated canonical names (empty → use inference)
|
||||||
|
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
|
||||||
|
InferredAmount *float64 // nil → fall back to Amount
|
||||||
|
Sender string
|
||||||
|
Message string
|
||||||
|
UserID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TxEntry is the portion of a payment allocated to a single member+month.
|
||||||
|
type TxEntry struct {
|
||||||
|
Amount float64
|
||||||
|
Date string
|
||||||
|
Sender string
|
||||||
|
Message string
|
||||||
|
Confidence string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OtherEntry is a payment with purpose "other:…" allocated to a member.
|
||||||
|
type OtherEntry struct {
|
||||||
|
Amount float64
|
||||||
|
Date string
|
||||||
|
Sender string
|
||||||
|
Message string
|
||||||
|
Purpose string
|
||||||
|
Confidence string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonthData is the ledger state for one member in one month.
|
||||||
|
type MonthData struct {
|
||||||
|
Expected int
|
||||||
|
OriginalExpected int
|
||||||
|
AttendanceCount int
|
||||||
|
Exception *Exception
|
||||||
|
Paid float64
|
||||||
|
Transactions []TxEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberResult is the reconciled ledger for one member.
|
||||||
|
type MemberResult struct {
|
||||||
|
Tier string
|
||||||
|
Months map[string]MonthData
|
||||||
|
OtherTransactions []OtherEntry
|
||||||
|
TotalBalance int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the top-level output of Reconcile.
|
||||||
|
type Result struct {
|
||||||
|
Members map[string]MemberResult
|
||||||
|
Unmatched []Transaction
|
||||||
|
Credits map[string]int // final balance for every member (may be negative)
|
||||||
|
}
|
||||||
|
|
||||||
|
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||||
|
|
||||||
|
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
|
||||||
|
// used to resolve Person-column values that drift from canonical attendance-sheet names.
|
||||||
|
// Ports scripts/match_payments.py canonical_member_key.
|
||||||
|
func canonicalMemberKey(name string) string {
|
||||||
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
type monthExpected struct {
|
||||||
|
month string
|
||||||
|
expected int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile matches transactions to members and months using three allocation phases:
|
||||||
|
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit.
|
||||||
|
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder.
|
||||||
|
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
||||||
|
//
|
||||||
|
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
||||||
|
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
||||||
|
//
|
||||||
|
// Ports scripts/match_payments.py reconcile.
|
||||||
|
func Reconcile(
|
||||||
|
members []Member,
|
||||||
|
sortedMonths []string,
|
||||||
|
transactions []Transaction,
|
||||||
|
exceptions map[ExceptionKey]Exception,
|
||||||
|
defaultYear int,
|
||||||
|
) Result {
|
||||||
|
memberNames := make([]string, len(members))
|
||||||
|
memberTiers := make(map[string]string, len(members))
|
||||||
|
memberFees := make(map[string]map[string]FeeData, len(members))
|
||||||
|
|
||||||
|
for i, m := range members {
|
||||||
|
memberNames[i] = m.Name
|
||||||
|
memberTiers[m.Name] = m.Tier
|
||||||
|
memberFees[m.Name] = m.Fees
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map canonical key → first attendance-sheet name with that key, so Person cells
|
||||||
|
// that drift in diacritics/case/whitespace still resolve to the canonical name.
|
||||||
|
canonicalByKey := make(map[string]string, len(memberNames))
|
||||||
|
for _, name := range memberNames {
|
||||||
|
key := canonicalMemberKey(name)
|
||||||
|
if _, exists := canonicalByKey[key]; !exists {
|
||||||
|
canonicalByKey[key] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if exceptions == nil {
|
||||||
|
exceptions = map[ExceptionKey]Exception{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise ledger
|
||||||
|
ledger := make(map[string]map[string]MonthData, len(memberNames))
|
||||||
|
otherLedger := make(map[string][]OtherEntry, len(memberNames))
|
||||||
|
|
||||||
|
for _, name := range memberNames {
|
||||||
|
ledger[name] = make(map[string]MonthData, len(sortedMonths))
|
||||||
|
otherLedger[name] = []OtherEntry{}
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
fd := memberFees[name][m]
|
||||||
|
originalExpected := fd.Expected
|
||||||
|
attendanceCount := fd.Attendance
|
||||||
|
|
||||||
|
var expected int
|
||||||
|
var exInfo *Exception
|
||||||
|
exKey := ExceptionKey{
|
||||||
|
Name: czech.Normalize(name),
|
||||||
|
Period: czech.Normalize(m),
|
||||||
|
}
|
||||||
|
if ex, ok := exceptions[exKey]; ok {
|
||||||
|
expected = ex.Amount
|
||||||
|
exCopy := ex
|
||||||
|
exInfo = &exCopy
|
||||||
|
} else {
|
||||||
|
expected = originalExpected
|
||||||
|
}
|
||||||
|
|
||||||
|
ledger[name][m] = MonthData{
|
||||||
|
Expected: expected,
|
||||||
|
OriginalExpected: originalExpected,
|
||||||
|
AttendanceCount: attendanceCount,
|
||||||
|
Exception: exInfo,
|
||||||
|
Paid: 0,
|
||||||
|
Transactions: []TxEntry{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmatched []Transaction
|
||||||
|
credits := make(map[string]int, len(memberNames))
|
||||||
|
|
||||||
|
for _, tx := range transactions {
|
||||||
|
personStr := strings.TrimSpace(tx.Person)
|
||||||
|
purposeStr := strings.TrimSpace(tx.Purpose)
|
||||||
|
personStr = questionMarkRe.ReplaceAllString(personStr, "")
|
||||||
|
isOther := strings.HasPrefix(strings.ToLower(purposeStr), "other:")
|
||||||
|
|
||||||
|
var matchedMembers []matching.Match
|
||||||
|
var matchedMonths []string
|
||||||
|
var amount float64
|
||||||
|
|
||||||
|
if personStr != "" && purposeStr != "" {
|
||||||
|
for p := range strings.SplitSeq(personStr, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
matchedMembers = append(matchedMembers, matching.Match{
|
||||||
|
Name: p,
|
||||||
|
Confidence: matching.ConfidenceAuto,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isOther {
|
||||||
|
matchedMonths = []string{purposeStr}
|
||||||
|
} else {
|
||||||
|
for m := range strings.SplitSeq(purposeStr, ",") {
|
||||||
|
m = strings.TrimSpace(m)
|
||||||
|
if m != "" {
|
||||||
|
matchedMonths = append(matchedMonths, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tx.InferredAmount != nil {
|
||||||
|
amount = *tx.InferredAmount
|
||||||
|
} else {
|
||||||
|
amount = tx.Amount
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Inference fallback for rows not yet processed by infer_payments.py
|
||||||
|
inferred := matching.InferTransactionDetails(
|
||||||
|
matching.Transaction{
|
||||||
|
Sender: tx.Sender,
|
||||||
|
Message: tx.Message,
|
||||||
|
UserID: tx.UserID,
|
||||||
|
Date: tx.Date,
|
||||||
|
},
|
||||||
|
memberNames,
|
||||||
|
defaultYear,
|
||||||
|
)
|
||||||
|
matchedMembers = inferred.Members
|
||||||
|
matchedMonths = inferred.Months
|
||||||
|
amount = tx.Amount
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matchedMembers) == 0 || len(matchedMonths) == 0 {
|
||||||
|
unmatched = append(unmatched, tx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOther {
|
||||||
|
nAlloc := len(matchedMembers)
|
||||||
|
perAlloc := 0.0
|
||||||
|
if nAlloc > 0 {
|
||||||
|
perAlloc = amount / float64(nAlloc)
|
||||||
|
}
|
||||||
|
for _, m := range matchedMembers {
|
||||||
|
memberName := canonicalByKey[canonicalMemberKey(m.Name)]
|
||||||
|
if memberName != "" {
|
||||||
|
otherLedger[memberName] = append(otherLedger[memberName], OtherEntry{
|
||||||
|
Amount: perAlloc,
|
||||||
|
Date: tx.Date,
|
||||||
|
Sender: tx.Sender,
|
||||||
|
Message: tx.Message,
|
||||||
|
Purpose: purposeStr,
|
||||||
|
Confidence: string(m.Confidence),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
memberShare := 0.0
|
||||||
|
if len(matchedMembers) > 0 {
|
||||||
|
memberShare = amount / float64(len(matchedMembers))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range matchedMembers {
|
||||||
|
memberName := canonicalByKey[canonicalMemberKey(m.Name)]
|
||||||
|
if memberName == "" {
|
||||||
|
unmatched = append(unmatched, tx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var inWindow []monthExpected
|
||||||
|
outCount := 0
|
||||||
|
for _, month := range matchedMonths {
|
||||||
|
if md, ok := ledger[memberName][month]; ok {
|
||||||
|
inWindow = append(inWindow, monthExpected{month: month, expected: md.Expected})
|
||||||
|
} else {
|
||||||
|
outCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nTotal := len(matchedMonths)
|
||||||
|
outCredit := 0.0
|
||||||
|
if outCount > 0 && nTotal > 0 {
|
||||||
|
outCredit = memberShare / float64(nTotal) * float64(outCount)
|
||||||
|
credits[memberName] += int(outCredit)
|
||||||
|
}
|
||||||
|
|
||||||
|
inWindowShare := memberShare - outCredit
|
||||||
|
|
||||||
|
if len(inWindow) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
totalExpected := 0
|
||||||
|
for _, mw := range inWindow {
|
||||||
|
totalExpected += mw.expected
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalExpected > 0 && inWindowShare >= float64(totalExpected) {
|
||||||
|
// Greedy: payment covers all expected fees; overflow → credit
|
||||||
|
credits[memberName] += int(inWindowShare - float64(totalExpected))
|
||||||
|
for _, mw := range inWindow {
|
||||||
|
alloc := float64(mw.expected)
|
||||||
|
md := ledger[memberName][mw.month]
|
||||||
|
md.Paid += alloc
|
||||||
|
md.Transactions = append(md.Transactions, TxEntry{
|
||||||
|
Amount: alloc,
|
||||||
|
Date: tx.Date,
|
||||||
|
Sender: tx.Sender,
|
||||||
|
Message: tx.Message,
|
||||||
|
Confidence: string(m.Confidence),
|
||||||
|
})
|
||||||
|
ledger[memberName][mw.month] = md
|
||||||
|
}
|
||||||
|
} else if totalExpected > 0 {
|
||||||
|
// Proportional: distribute by each month's share; last month absorbs float remainder
|
||||||
|
remaining := inWindowShare
|
||||||
|
for i, mw := range inWindow {
|
||||||
|
var alloc float64
|
||||||
|
if i == len(inWindow)-1 {
|
||||||
|
alloc = remaining
|
||||||
|
} else {
|
||||||
|
alloc = inWindowShare * float64(mw.expected) / float64(totalExpected)
|
||||||
|
}
|
||||||
|
remaining -= alloc
|
||||||
|
md := ledger[memberName][mw.month]
|
||||||
|
md.Paid += alloc
|
||||||
|
md.Transactions = append(md.Transactions, TxEntry{
|
||||||
|
Amount: alloc,
|
||||||
|
Date: tx.Date,
|
||||||
|
Sender: tx.Sender,
|
||||||
|
Message: tx.Message,
|
||||||
|
Confidence: string(m.Confidence),
|
||||||
|
})
|
||||||
|
ledger[memberName][mw.month] = md
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Even-split fallback: prepayment before attendance recorded
|
||||||
|
perMonth := inWindowShare / float64(len(inWindow))
|
||||||
|
for _, mw := range inWindow {
|
||||||
|
md := ledger[memberName][mw.month]
|
||||||
|
md.Paid += perMonth
|
||||||
|
md.Transactions = append(md.Transactions, TxEntry{
|
||||||
|
Amount: perMonth,
|
||||||
|
Date: tx.Date,
|
||||||
|
Sender: tx.Sender,
|
||||||
|
Message: tx.Message,
|
||||||
|
Confidence: string(m.Confidence),
|
||||||
|
})
|
||||||
|
ledger[memberName][mw.month] = md
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final total balances: window balance + out-of-window credits accumulated above
|
||||||
|
finalBalances := make(map[string]int, len(memberNames))
|
||||||
|
for _, name := range memberNames {
|
||||||
|
windowBalance := 0
|
||||||
|
for _, mdata := range ledger[name] {
|
||||||
|
windowBalance += int(mdata.Paid) - mdata.Expected
|
||||||
|
}
|
||||||
|
finalBalances[name] = windowBalance + credits[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
membersResult := make(map[string]MemberResult, len(memberNames))
|
||||||
|
for _, name := range memberNames {
|
||||||
|
membersResult[name] = MemberResult{
|
||||||
|
Tier: memberTiers[name],
|
||||||
|
Months: ledger[name],
|
||||||
|
OtherTransactions: otherLedger[name],
|
||||||
|
TotalBalance: finalBalances[name],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if unmatched == nil {
|
||||||
|
unmatched = []Transaction{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{
|
||||||
|
Members: membersResult,
|
||||||
|
Unmatched: unmatched,
|
||||||
|
Credits: finalBalances,
|
||||||
|
}
|
||||||
|
}
|
||||||
376
go/internal/domain/reconcile/reconcile_test.go
Normal file
376
go/internal/domain/reconcile/reconcile_test.go
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
package reconcile
|
||||||
|
|
||||||
|
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
||||||
|
//
|
||||||
|
// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
|
||||||
|
//
|
||||||
|
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultYear = 2026
|
||||||
|
|
||||||
|
// tx builds a pre-matched Transaction (person+purpose already filled in).
|
||||||
|
// InferredAmount is left nil so Amount is used directly, matching the Python
|
||||||
|
// _tx helper where inferred_amount == amount.
|
||||||
|
func tx(person, purpose string, amount float64) Transaction {
|
||||||
|
return Transaction{
|
||||||
|
Date: "2026-01-01",
|
||||||
|
Amount: amount,
|
||||||
|
Person: person,
|
||||||
|
Purpose: purpose,
|
||||||
|
Sender: "Sender",
|
||||||
|
Message: "fee",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileExceptionOverride(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
||||||
|
exceptions := map[ExceptionKey]Exception{
|
||||||
|
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"},
|
||||||
|
}
|
||||||
|
txs := []Transaction{{
|
||||||
|
Date: "2026-01-05", Amount: 400,
|
||||||
|
Person: "Alice", Purpose: "2026-01", Sender: "Alice Sender", Message: "fee",
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-01"}, txs, exceptions, defaultYear)
|
||||||
|
|
||||||
|
jan := result.Members["Alice"].Months["2026-01"]
|
||||||
|
if jan.Expected != 400 {
|
||||||
|
t.Errorf("Expected override to 400, got %d", jan.Expected)
|
||||||
|
}
|
||||||
|
if jan.Paid != 400 {
|
||||||
|
t.Errorf("Paid want 400, got %f", jan.Paid)
|
||||||
|
}
|
||||||
|
if result.Members["Alice"].TotalBalance != 0 {
|
||||||
|
t.Errorf("TotalBalance want 0, got %d", result.Members["Alice"].TotalBalance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileFallbackToAttendance(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||||
|
|
||||||
|
if result.Members["Alice"].Months["2026-01"].Expected != 750 {
|
||||||
|
t.Errorf("Expected 750 when no exception, got %d", result.Members["Alice"].Months["2026-01"].Expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileGreedyExactMatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{
|
||||||
|
Name: "Alice", Tier: "A",
|
||||||
|
Fees: map[string]FeeData{
|
||||||
|
"2026-02": {750, 3},
|
||||||
|
"2026-03": {350, 3},
|
||||||
|
"2026-04": {150, 2},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||||
|
|
||||||
|
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, nil, defaultYear)
|
||||||
|
|
||||||
|
months := result.Members["Alice"].Months
|
||||||
|
if int(months["2026-02"].Paid) != 750 {
|
||||||
|
t.Errorf("2026-02 paid want 750, got %f", months["2026-02"].Paid)
|
||||||
|
}
|
||||||
|
if int(months["2026-03"].Paid) != 350 {
|
||||||
|
t.Errorf("2026-03 paid want 350, got %f", months["2026-03"].Paid)
|
||||||
|
}
|
||||||
|
if int(months["2026-04"].Paid) != 150 {
|
||||||
|
t.Errorf("2026-04 paid want 150, got %f", months["2026-04"].Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{
|
||||||
|
Name: "Alice", Tier: "A",
|
||||||
|
Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}},
|
||||||
|
}}
|
||||||
|
sortedMonths := []string{"2026-01", "2026-02"}
|
||||||
|
|
||||||
|
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-01, 2026-02", 2000)}, nil, defaultYear)
|
||||||
|
|
||||||
|
months := result.Members["Alice"].Months
|
||||||
|
if int(months["2026-01"].Paid) != 750 {
|
||||||
|
t.Errorf("2026-01 paid want 750, got %f", months["2026-01"].Paid)
|
||||||
|
}
|
||||||
|
if int(months["2026-02"].Paid) != 750 {
|
||||||
|
t.Errorf("2026-02 paid want 750, got %f", months["2026-02"].Paid)
|
||||||
|
}
|
||||||
|
if result.Credits["Alice"] != 500 {
|
||||||
|
t.Errorf("credits want 500, got %d", result.Credits["Alice"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileProportionalUnderpayment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{
|
||||||
|
Name: "Alice", Tier: "A",
|
||||||
|
Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}},
|
||||||
|
}}
|
||||||
|
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||||
|
amount := 1250.0
|
||||||
|
|
||||||
|
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", amount)}, nil, defaultYear)
|
||||||
|
|
||||||
|
months := result.Members["Alice"].Months
|
||||||
|
paid02 := months["2026-02"].Paid
|
||||||
|
paid03 := months["2026-03"].Paid
|
||||||
|
paid04 := months["2026-04"].Paid
|
||||||
|
|
||||||
|
if paid02 >= 750 {
|
||||||
|
t.Errorf("2026-02 should be underpaid, got %f", paid02)
|
||||||
|
}
|
||||||
|
if paid03 >= 350 {
|
||||||
|
t.Errorf("2026-03 should be underpaid, got %f", paid03)
|
||||||
|
}
|
||||||
|
if paid04 >= 750 {
|
||||||
|
t.Errorf("2026-04 should be underpaid, got %f", paid04)
|
||||||
|
}
|
||||||
|
if math.Abs(paid02+paid03+paid04-amount) > 0.01 {
|
||||||
|
t.Errorf("sum of paid want %f, got %f", amount, paid02+paid03+paid04)
|
||||||
|
}
|
||||||
|
if math.Abs(paid02-paid04) > 0.01 {
|
||||||
|
t.Errorf("02 and 04 have equal expected, want equal paid: %f vs %f", paid02, paid04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear)
|
||||||
|
|
||||||
|
if math.Abs(result.Members["Alice"].Months["2026-01"].Paid-750) > 0.01 {
|
||||||
|
t.Errorf("single month want 750, got %f", result.Members["Alice"].Months["2026-01"].Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileTwoMembersMultiMonth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{
|
||||||
|
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
||||||
|
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
||||||
|
}
|
||||||
|
sortedMonths := []string{"2026-01", "2026-02"}
|
||||||
|
|
||||||
|
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice, Bob", "2026-01, 2026-02", 2200)}, nil, defaultYear)
|
||||||
|
|
||||||
|
for _, name := range []string{"Alice", "Bob"} {
|
||||||
|
months := result.Members[name].Months
|
||||||
|
if math.Abs(months["2026-01"].Paid-750) > 0.01 {
|
||||||
|
t.Errorf("%s 2026-01 paid want 750, got %f", name, months["2026-01"].Paid)
|
||||||
|
}
|
||||||
|
if math.Abs(months["2026-02"].Paid-350) > 0.01 {
|
||||||
|
t.Errorf("%s 2026-02 paid want 350, got %f", name, months["2026-02"].Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{
|
||||||
|
Name: "Alice", Tier: "A",
|
||||||
|
Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}},
|
||||||
|
}}
|
||||||
|
sortedMonths := []string{"2026-01", "2026-02"}
|
||||||
|
|
||||||
|
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-01, 2026-02", 300)}, nil, defaultYear)
|
||||||
|
|
||||||
|
months := result.Members["Alice"].Months
|
||||||
|
if math.Abs(months["2026-01"].Paid-150) > 0.01 {
|
||||||
|
t.Errorf("2026-01 paid want 150, got %f", months["2026-01"].Paid)
|
||||||
|
}
|
||||||
|
if math.Abs(months["2026-02"].Paid-150) > 0.01 {
|
||||||
|
t.Errorf("2026-02 paid want 150, got %f", months["2026-02"].Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
||||||
|
txFn := func(person string) Transaction {
|
||||||
|
return Transaction{
|
||||||
|
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
|
||||||
|
Sender: "Maco Family", Message: "fee",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
person string
|
||||||
|
}{
|
||||||
|
{"without diacritics", "Maria Maco"},
|
||||||
|
{"extra whitespace", "Mária Maco"},
|
||||||
|
{"lowercase", "mária maco"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
result := Reconcile(members, []string{"2026-04"}, []Transaction{txFn(tc.person)}, nil, defaultYear)
|
||||||
|
|
||||||
|
paid := result.Members["Mária Maco"].Months["2026-04"].Paid
|
||||||
|
if paid != 750 {
|
||||||
|
t.Errorf("%s: paid want 750, got %f", tc.name, paid)
|
||||||
|
}
|
||||||
|
if len(result.Unmatched) != 0 {
|
||||||
|
t.Errorf("%s: want no unmatched, got %v", tc.name, result.Unmatched)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
||||||
|
txs := []Transaction{{
|
||||||
|
Date: "2026-04-15", Amount: 750,
|
||||||
|
Person: "Někdo Neznámý", Purpose: "2026-04",
|
||||||
|
Sender: "Neznámý", Message: "fee",
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-04"}, txs, nil, defaultYear)
|
||||||
|
|
||||||
|
if result.Members["Mária Maco"].Months["2026-04"].Paid != 0 {
|
||||||
|
t.Errorf("unknown person must not credit the member")
|
||||||
|
}
|
||||||
|
if len(result.Unmatched) != 1 {
|
||||||
|
t.Errorf("want 1 unmatched, got %d", len(result.Unmatched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Go] Test that [?] markers are stripped from the Person field before lookup.
|
||||||
|
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||||
|
txs := []Transaction{{
|
||||||
|
Date: "2026-01-01", Amount: 750,
|
||||||
|
Person: "[?] Alice", Purpose: "2026-01",
|
||||||
|
Sender: "Bank", Message: "fee",
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear)
|
||||||
|
|
||||||
|
if result.Members["Alice"].Months["2026-01"].Paid != 750 {
|
||||||
|
t.Errorf("[?] stripping: want 750 paid, got %f", result.Members["Alice"].Months["2026-01"].Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
|
||||||
|
func TestReconcileOtherPurpose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||||
|
txs := []Transaction{{
|
||||||
|
Date: "2026-01-01", Amount: 300,
|
||||||
|
Person: "Alice", Purpose: "other:shirt",
|
||||||
|
Sender: "Bank", Message: "shirt order",
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear)
|
||||||
|
|
||||||
|
if result.Members["Alice"].Months["2026-01"].Paid != 0 {
|
||||||
|
t.Errorf("other: purpose must not touch month ledger")
|
||||||
|
}
|
||||||
|
others := result.Members["Alice"].OtherTransactions
|
||||||
|
if len(others) != 1 {
|
||||||
|
t.Fatalf("want 1 OtherTransaction, got %d", len(others))
|
||||||
|
}
|
||||||
|
if math.Abs(others[0].Amount-300) > 0.01 {
|
||||||
|
t.Errorf("OtherEntry.Amount want 300, got %f", others[0].Amount)
|
||||||
|
}
|
||||||
|
if others[0].Purpose != "other:shirt" {
|
||||||
|
t.Errorf("OtherEntry.Purpose want %q, got %q", "other:shirt", others[0].Purpose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Go] Months outside sortedMonths go to credit, not to the window ledger.
|
||||||
|
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}}
|
||||||
|
txs := []Transaction{{
|
||||||
|
Date: "2026-01-01", Amount: 1200,
|
||||||
|
Person: "Alice", Purpose: "2026-01, 2026-02",
|
||||||
|
Sender: "Bank", Message: "Q1",
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear)
|
||||||
|
|
||||||
|
// member_share = 1200 (one member)
|
||||||
|
// out_credit = 1200 / 2 * 1 = 600
|
||||||
|
// in_window_share = 600
|
||||||
|
// in_window = [(2026-01, 600)], total_expected = 600 → greedy: paid = 600, no overflow
|
||||||
|
if math.Abs(result.Members["Alice"].Months["2026-01"].Paid-600) > 0.01 {
|
||||||
|
t.Errorf("in-window paid want 600, got %f", result.Members["Alice"].Months["2026-01"].Paid)
|
||||||
|
}
|
||||||
|
// total_balance = int(600) - 600 (window) + 600 (out credit) = 600
|
||||||
|
if result.Members["Alice"].TotalBalance != 600 {
|
||||||
|
t.Errorf("TotalBalance want 600, got %d", result.Members["Alice"].TotalBalance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Go] No person/purpose → inference fallback resolves sender name and date month.
|
||||||
|
func TestReconcileInferenceFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}}
|
||||||
|
txs := []Transaction{{
|
||||||
|
Date: "2026-04-15", Amount: 750,
|
||||||
|
// Person and Purpose are empty → inference path
|
||||||
|
Sender: "Tomas Nemecek",
|
||||||
|
Message: "clenske 04/2026",
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-04"}, txs, nil, defaultYear)
|
||||||
|
|
||||||
|
if math.Abs(result.Members["Tomáš Němeček"].Months["2026-04"].Paid-750) > 0.01 {
|
||||||
|
t.Errorf("inference fallback: want 750 paid, got %f", result.Members["Tomáš Němeček"].Months["2026-04"].Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
|
||||||
|
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||||
|
txs := []Transaction{{
|
||||||
|
Date: "2026-01-01", Amount: 500,
|
||||||
|
// empty person+purpose and sender name not matching any member
|
||||||
|
Sender: "Unknown Corp", Message: "invoice",
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear)
|
||||||
|
|
||||||
|
if len(result.Unmatched) != 1 {
|
||||||
|
t.Errorf("want 1 unmatched, got %d", len(result.Unmatched))
|
||||||
|
}
|
||||||
|
if result.Members["Alice"].Months["2026-01"].Paid != 0 {
|
||||||
|
t.Errorf("unmatched tx must not touch ledger")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Go] Empty transaction list leaves every month at paid=0 and balance=–expected.
|
||||||
|
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||||
|
|
||||||
|
if result.Members["Alice"].Months["2026-01"].Paid != 0 {
|
||||||
|
t.Errorf("no txs: want paid=0, got %f", result.Members["Alice"].Months["2026-01"].Paid)
|
||||||
|
}
|
||||||
|
if result.Members["Alice"].TotalBalance != -750 {
|
||||||
|
t.Errorf("no txs: want balance -750, got %d", result.Members["Alice"].TotalBalance)
|
||||||
|
}
|
||||||
|
if len(result.Unmatched) != 0 {
|
||||||
|
t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
go/internal/services/membership/doc.go
Normal file
4
go/internal/services/membership/doc.go
Normal file
@@ -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
|
||||||
17
go/internal/services/membership/fees.go
Normal file
17
go/internal/services/membership/fees.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
47
go/internal/services/membership/fees_test.go
Normal file
47
go/internal/services/membership/fees_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
89
go/internal/services/membership/format_fees.go
Normal file
89
go/internal/services/membership/format_fees.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
99
go/internal/services/membership/format_fees_test.go
Normal file
99
go/internal/services/membership/format_fees_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
192
go/internal/services/membership/format_reconcile.go
Normal file
192
go/internal/services/membership/format_reconcile.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
203
go/internal/services/membership/format_reconcile_test.go
Normal file
203
go/internal/services/membership/format_reconcile_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
go/internal/services/membership/loader.go
Normal file
50
go/internal/services/membership/loader.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
30
go/internal/services/membership/reconcile.go
Normal file
30
go/internal/services/membership/reconcile.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
68
go/internal/services/membership/reconcile_test.go
Normal file
68
go/internal/services/membership/reconcile_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
27
go/internal/services/membership/stub_test.go
Normal file
27
go/internal/services/membership/stub_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user