Compare commits
14 Commits
feat/m2-5-
...
feat/m3-fi
| Author | SHA1 | Date | |
|---|---|---|---|
| 67d2f11d7c | |||
| 28f0e468f7 | |||
| 8386af8078 | |||
| 56aa2303a8 | |||
| ea8622a541 | |||
| 71278e6f7a | |||
| 34ce0be5a0 | |||
| c5a8a4e7b1 | |||
| 3e597242eb | |||
| 7232697e9c | |||
| e596f0000e | |||
| c2bffed1b8 | |||
| 54a783ea00 | |||
| 84a5d177e9 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -1,5 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-06 23:25 CEST — feat(go/M3): fixture capture + parity test framework
|
||||
|
||||
- `scripts/capture_fixtures.py`: dispatcher CLI that calls each ported function with seeded inputs and emits captured output as JSON fixtures.
|
||||
- `scripts/scrub_fixtures.py`: deterministic PII scrubber (SHA-256 pseudonyms, digit-preserving account/VS hashes, name-sweep in free text).
|
||||
- `scripts/_fixture_seeds.py`: handcrafted seed registry for all 10 pure functions + 10 reconcile branch-coverage cases.
|
||||
- 98 fixture files committed under `go/tests/fixtures/pure/<func>/` and `go/tests/fixtures/reconcile/`; all PII-free.
|
||||
- `go/tests/parity/parityio.go`: shared loader with generic `LoadDir`/`RunAll` helpers and typed `In`/`Out` structs for all 10 functions.
|
||||
- 11 parity test packages under `//go:build parity`: 10 pure-function tests + bespoke reconcile test with per-cell float tolerance.
|
||||
- Makefile: `go-parity`, `go-test-all`, `capture-fixtures` targets.
|
||||
- `go/tests/fixtures/README.md`: refresh workflow, PII audit guide, adding-a-fixture steps.
|
||||
|
||||
## 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.
|
||||
- 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`.
|
||||
## 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
|
||||
|
||||
- New `go/internal/domain/matching` package porting three helpers from `scripts/match_payments.py`.
|
||||
- `BuildNameVariants` — extracts normalized ASCII search variants from a member name, including nickname (from parens) and separate first/last; filters variants shorter than 3 chars; `variants[0]` is always the full normalized base name.
|
||||
- `MatchMembers` — finds members in free text with `"auto"` or `"review"` confidence; exact-name short-circuit prevents nickname substrings (e.g. `tov`) from matching inside surnames (e.g. `ottova`).
|
||||
- `FormatDate` — normalizes Google Sheets date values: handles nil, empty, int/float64 serial-days since 1899-12-30 (supports fractional serials), pre-formatted `YYYY-MM-DD` strings, and garbage input — never errors.
|
||||
- `InferTransactionDetails` — composes name + month matching over sender/message/user_id; falls back to sender-only member match and date-derived month when text gives no signal.
|
||||
- 21 table-driven tests; all expected values verified against live Python on 2026-05-06.
|
||||
|
||||
## 2026-05-06 12:43 CEST — feat(go/M2.6): port domain/synch.GenerateSyncID
|
||||
|
||||
- New `go/internal/domain/synch` package with `GenerateSyncID(Transaction) string` ported from `scripts/sync_fio_to_sheets.py` `generate_sync_id`.
|
||||
- Byte-stable SHA-256 hash over `date|amount|currency|sender|vs|message|bank_id` (lowercased, UTF-8); `Currency: ""` defaults to `"CZK"` matching the Python missing-key fallback.
|
||||
- Key subtlety: Python's `str(float)` emits `"500.0"` for whole-valued floats and switches to scientific notation at `|f| >= 1e16` or `|f| < 1e-4` — replicated in `formatAmount` using `'f'`/`'e'` format selection.
|
||||
- 6 table-driven hash tests + 9 `formatAmount` tests; all expected values verified against live Python on 2026-05-06.
|
||||
|
||||
## 2026-05-06 09:38 CEST — feat(go/M2.5): port domain/money.ParseCZK
|
||||
|
||||
- New `go/internal/domain/money` package with `ParseCZK(string) (float64, error)` ported from `scripts/infer_payments.py` `parse_czk_amount`.
|
||||
|
||||
28
Makefile
28
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-run go-lint image run sync sync-2026 test test-v docs
|
||||
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-lint capture-fixtures image run sync sync-2026 test test-v docs
|
||||
|
||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
||||
VENV := .venv
|
||||
@@ -23,8 +23,11 @@ help:
|
||||
@echo " make web-go - Build and start Go dashboard on :8080"
|
||||
@echo " make web-debug - Start Python dashboard in debug mode"
|
||||
@echo " make go-build - Build Go binary to bin/fuj"
|
||||
@echo " make go-test - Run Go tests"
|
||||
@echo " make go-test - Run Go unit tests"
|
||||
@echo " make go-parity - Run Go parity tests (requires -tags=parity fixture corpus)"
|
||||
@echo " make go-test-all - Run both unit and parity tests"
|
||||
@echo " make go-lint - Run golangci-lint on Go code"
|
||||
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
|
||||
@echo " make image - Build Python OCI container image"
|
||||
@echo " make run - Run the built Python Docker image locally"
|
||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||
@@ -64,6 +67,27 @@ go-build:
|
||||
go-test:
|
||||
cd $(GO_SRC) && go test -race ./...
|
||||
|
||||
go-parity:
|
||||
cd $(GO_SRC) && go test -tags=parity ./tests/parity/...
|
||||
|
||||
go-test-all: go-test go-parity
|
||||
|
||||
capture-fixtures: $(PYTHON)
|
||||
@echo "Capturing and scrubbing fixtures for all registered functions..."
|
||||
@for func in normalize parse_month_references calculate_fee calculate_junior_fee \
|
||||
parse_czk_amount generate_sync_id build_name_variants match_members \
|
||||
infer_transaction_details format_date reconcile; do \
|
||||
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
|
||||
mkdir -p "$$dir"; \
|
||||
PYTHONPATH=scripts:. $(PYTHON) scripts/capture_fixtures.py --func $$func --all \
|
||||
| while IFS= read -r line; do \
|
||||
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
|
||||
echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
|
||||
done; \
|
||||
echo " $$func done"; \
|
||||
done
|
||||
@echo "capture-fixtures complete."
|
||||
|
||||
go-run: go-build
|
||||
./$(GO_BIN) $(ARGS)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
||||
|
||||
**Current milestone:** M2 — Pure-domain helpers
|
||||
**Current milestone:** M3 — Fixture capture + characterization framework ✅
|
||||
**Started:** 2026-05-04
|
||||
**Last updated:** 2026-05-06
|
||||
|
||||
@@ -49,13 +49,13 @@ Each task: port the function, write Go unit tests for fresh cases, hook into the
|
||||
- [x] **M2.3** `domain/fees.CalculateFee` — port [attendance.py](scripts/attendance.py) `calculate_fee` (constants table) — `0fc3b6d`
|
||||
- [x] **M2.4** `domain/fees.CalculateJuniorFee` — port `calculate_junior_fee` with `Expected{Value int; Unknown bool}` for the `"?"` sentinel — `0fc3b6d`
|
||||
- [x] **M2.5** `domain/money.ParseCZK` — port [infer_payments.py](scripts/infer_payments.py) `parse_czk_amount` (Czech locale: comma decimal, dot/space thousand separators) — `d24d205`
|
||||
- [ ] **M2.6** `domain/synch.GenerateSyncID` — port [sync_fio_to_sheets.py](scripts/sync_fio_to_sheets.py) `generate_sync_id` (SHA-256, byte-stable hash; verify float string format against real sheet rows)
|
||||
- [ ] **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)
|
||||
- [ ] **M2.8** `domain/matching.InferTransactionDetails` — port `infer_transaction_details` (composes name + month parsing)
|
||||
- [ ] **M2.9** `domain/matching.FormatDate` — port `format_date` (handles Google Sheets serial-day numbers since 1899-12-30)
|
||||
- [ ] **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.
|
||||
- [ ] **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.6** `domain/synch.GenerateSyncID` — port [sync_fio_to_sheets.py](scripts/sync_fio_to_sheets.py) `generate_sync_id` (SHA-256, byte-stable hash; verify float string format against real sheet rows)
|
||||
- [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.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 — `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/`.
|
||||
|
||||
@@ -65,14 +65,14 @@ Each task: port the function, write Go unit tests for fresh cases, hook into the
|
||||
|
||||
Goal: deterministic, PII-free fixture corpus that drives parity tests. Runs in parallel with M2 (M3.1/M3.2 unblocks M2.1).
|
||||
|
||||
- [ ] **M3.1** `scripts/capture_fixtures.py` — pure-function output dumper. Reads inputs from stdin / argv, prints `{"input":..., "output":...}` JSON
|
||||
- [ ] **M3.2** `scripts/scrub_fixtures.py` — replaces names with `Member_<8hex>` (deterministic per name); scrambles sender/account/VS/bank_id with stable bijection; preserves dates, amounts, exception keys
|
||||
- [ ] **M3.3** Capture pure-fn fixtures for M2.1–M2.9 (run helper + scrubber, commit to `tests/fixtures/pure/<func>/<case>.json`)
|
||||
- [ ] **M3.4** Capture ~10 reconcile fixtures spanning every code path: greedy, proportional (float remainder), even-split, out-of-window credit, exception override, `other:` purpose, junior `"?"`, multi-person comma-split, multi-month range, unmatched. Commit to `tests/fixtures/reconcile/`
|
||||
- [ ] **M3.5** Hook fixtures into Tier-1 test runner with `-tags=parity` build constraint
|
||||
- [ ] **M3.6** Document fixture-refresh workflow in `tests/fixtures/README.md` (what to do when sheet schema changes)
|
||||
- [x] **M3.1** `scripts/capture_fixtures.py` — pure-function output dumper. Reads inputs from stdin / argv, prints `{"input":..., "output":...}` JSON
|
||||
- [x] **M3.2** `scripts/scrub_fixtures.py` — replaces names with `Member_<8hex>` (deterministic per name); scrambles sender/account/VS/bank_id with stable bijection; preserves dates, amounts, exception keys
|
||||
- [x] **M3.3** Capture pure-fn fixtures for M2.1–M2.9 (run helper + scrubber, commit to `tests/fixtures/pure/<func>/<case>.json`)
|
||||
- [x] **M3.4** Capture ~10 reconcile fixtures spanning every code path: greedy, proportional (float remainder), even-split, out-of-window credit, exception override, `other:` purpose, junior `"?"`, multi-person comma-split, multi-month range, unmatched. Commit to `tests/fixtures/reconcile/`
|
||||
- [x] **M3.5** Hook fixtures into Tier-1 test runner with `-tags=parity` build constraint
|
||||
- [x] **M3.6** Document fixture-refresh workflow in `tests/fixtures/README.md` (what to do when sheet schema changes)
|
||||
|
||||
**Gate:** `tests/fixtures/` populated; M2 parity tests green; raw `tmp/*.json` confirmed gitignored.
|
||||
**Gate:** ✅ `tests/fixtures/` populated (98 files); `make go-parity` green; `make go-lint` (parity tag) clean; raw `tmp/*.json` confirmed gitignored.
|
||||
|
||||
---
|
||||
|
||||
|
||||
265
docs/plans/2026-05-06-1236-go-m2-6-synch-generate-sync-id.md
Normal file
265
docs/plans/2026-05-06-1236-go-m2-6-synch-generate-sync-id.md
Normal file
@@ -0,0 +1,265 @@
|
||||
|
||||
## Context
|
||||
|
||||
Continuing the Go backend rewrite tracked in
|
||||
[2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md).
|
||||
M2.1–M2.5 are landed. Next leaf-level pure function is `generate_sync_id`
|
||||
from [scripts/sync_fio_to_sheets.py:62-77](../../srv/personal/fuj-management/scripts/sync_fio_to_sheets.py#L62-L77).
|
||||
|
||||
It computes a SHA-256 hash over a fixed seven-field projection of a Fio
|
||||
transaction (`date|amount|currency|sender|vs|message|bank_id`) and is
|
||||
the deduplication key written into column K (`Sync ID`) of the payments
|
||||
sheet. The Go port must produce a **byte-identical** digest for the same
|
||||
transaction; otherwise the Go-side sync (M4.7) would re-append rows
|
||||
already written by the Python sync, double-counting payments.
|
||||
|
||||
The non-trivial part is the `amount` field's string serialisation:
|
||||
upstream `fio_utils.py` always supplies `amount` as a Python `float`
|
||||
(API path: `float(val(1) or 0)`; HTML path: `parse_czech_amount(...)`
|
||||
which returns `float`). Python's `str(float)` produces `"500.0"` for
|
||||
whole-valued floats; Go's `strconv.FormatFloat(f, 'g', -1, 64)` produces
|
||||
`"500"`. This is the gotcha called out in the M2.6 line of the progress
|
||||
tracker.
|
||||
|
||||
## Python behaviour (the spec)
|
||||
|
||||
```py
|
||||
def generate_sync_id(tx: dict) -> str:
|
||||
components = [
|
||||
str(tx.get("date", "")),
|
||||
str(tx.get("amount", "")),
|
||||
str(tx.get("currency", "CZK")),
|
||||
str(tx.get("sender", "")),
|
||||
str(tx.get("vs", "")),
|
||||
str(tx.get("message", "")),
|
||||
str(tx.get("bank_id", "")),
|
||||
]
|
||||
raw_str = "|".join(components).lower()
|
||||
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
||||
```
|
||||
|
||||
Behavioural notes for the Go port:
|
||||
|
||||
1. **Field order is load-bearing.** `date|amount|currency|sender|vs|message|bank_id` exactly.
|
||||
2. **Separator is `"|"`.**
|
||||
3. **Whole string is `.lower()`-ed before hashing** (so e.g. "ABC" sender vs "abc" hash identically). Unicode lower; in practice Fio data is ASCII + Czech diacritics.
|
||||
4. **`currency` defaults to `"CZK"`** when missing from the dict (HTML scraper path never sets it). Other fields default to `""`.
|
||||
5. **`amount` is a `float`.** Always. Real Fio data is `500.0`, `1234.56`, etc. — no NaN/Inf, but parity test must pin the format.
|
||||
6. **Output is `hashlib.sha256(...).hexdigest()`** — 64-char lowercase hex.
|
||||
7. **Encoding is UTF-8.**
|
||||
|
||||
### `str(float)` cases observed in real Fio amounts
|
||||
|
||||
| float64 | Python `str(f)` | Go `strconv.FormatFloat(f,'g',-1,64)` | Need |
|
||||
|---|---|---|---|
|
||||
| `500.0` | `"500.0"` | `"500"` | append `.0` |
|
||||
| `1234.56` | `"1234.56"` | `"1234.56"` | matches |
|
||||
| `0.0` | `"0.0"` | `"0"` | append `.0` |
|
||||
| `-500.0` | `"-500.0"` | `"-500"` | append `.0` |
|
||||
| `0.1` | `"0.1"` | `"0.1"` | matches |
|
||||
| `99999.99` | `"99999.99"` | `"99999.99"` | matches |
|
||||
|
||||
For the Fio amount domain (signed CZK, ≤ ~7 digits, ≤2 decimal places),
|
||||
the rule "`'g'` with prec -1, then append `.0` if result has no `.` and
|
||||
no `e`/`E`" is exact. We do not need to handle Python's
|
||||
scientific-notation crossover (`>= 1e16`) for real data, but the
|
||||
implementation should still cope with it correctly via the same rule.
|
||||
|
||||
## Approach
|
||||
|
||||
Create new package `internal/domain/synch` mirroring the layout of
|
||||
`internal/domain/money` (single-file module + test file alongside).
|
||||
|
||||
### Package + signature
|
||||
|
||||
```go
|
||||
// Package synch ports the bank-sync deduplication helper from
|
||||
// scripts/sync_fio_to_sheets.py.
|
||||
package synch
|
||||
|
||||
// Transaction is the projection of a Fio transaction that participates
|
||||
// in the Sync ID hash. Other fields (ks, ss, sender_account, …) are
|
||||
// intentionally excluded — they are not part of the Python hash.
|
||||
//
|
||||
// Currency: leave "" to inherit the Python default of "CZK" (matches
|
||||
// the HTML scraper path which omits the key entirely).
|
||||
type Transaction struct {
|
||||
Date string
|
||||
Amount float64
|
||||
Currency string
|
||||
Sender string
|
||||
VS string
|
||||
Message string
|
||||
BankID string
|
||||
}
|
||||
|
||||
// GenerateSyncID returns the lowercase SHA-256 hex digest of
|
||||
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
|
||||
// as the dedup key in column K of the payments sheet.
|
||||
//
|
||||
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id.
|
||||
func GenerateSyncID(tx Transaction) string
|
||||
```
|
||||
|
||||
### `Currency` default
|
||||
|
||||
In Go every struct field is always present, so we lose Python's
|
||||
"missing key vs empty string" distinction. Real-world data either sets
|
||||
`currency = "CZK"` (API path) or omits the key (HTML path → `"CZK"`
|
||||
default). Empty string never occurs in practice. The Go port collapses
|
||||
the two by treating `Currency == ""` as "use `CZK`":
|
||||
|
||||
```go
|
||||
currency := tx.Currency
|
||||
if currency == "" {
|
||||
currency = "CZK"
|
||||
}
|
||||
```
|
||||
|
||||
This is byte-equal to Python for every input we will ever see in
|
||||
production, and avoids forcing callers to pass a `*string`.
|
||||
|
||||
### Float formatter
|
||||
|
||||
Internal helper, unexported:
|
||||
|
||||
```go
|
||||
// formatAmount mimics Python's str(float) for the float values that
|
||||
// appear in Fio transactions. For mundane decimal amounts the rule
|
||||
// is: format with 'g' precision -1, then append ".0" if the result
|
||||
// has no decimal point and no exponent.
|
||||
func formatAmount(f float64) string {
|
||||
s := strconv.FormatFloat(f, 'g', -1, 64)
|
||||
if !strings.ContainsAny(s, ".eE") {
|
||||
s += ".0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
Tested explicitly (see Tests below) so the edge cases (`0`, whole
|
||||
numbers, negatives, large/small with exponent) stay locked.
|
||||
|
||||
### Hash composition
|
||||
|
||||
```go
|
||||
func GenerateSyncID(tx Transaction) string {
|
||||
currency := tx.Currency
|
||||
if currency == "" {
|
||||
currency = "CZK"
|
||||
}
|
||||
raw := strings.ToLower(strings.Join([]string{
|
||||
tx.Date,
|
||||
formatAmount(tx.Amount),
|
||||
currency,
|
||||
tx.Sender,
|
||||
tx.VS,
|
||||
tx.Message,
|
||||
tx.BankID,
|
||||
}, "|"))
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
```
|
||||
|
||||
(`crypto/sha256` + `encoding/hex` — both stdlib, no `go.mod` change.)
|
||||
|
||||
## Tests
|
||||
|
||||
`synch_test.go` mirrors `money_test.go`'s table-driven style with the
|
||||
verification snippet at the top of the function. Two test functions:
|
||||
|
||||
### 1. `TestGenerateSyncID`
|
||||
|
||||
Each row's expected digest is computed from the Python source:
|
||||
|
||||
```sh
|
||||
PYTHONPATH=scripts:. python -c '
|
||||
from sync_fio_to_sheets import generate_sync_id
|
||||
cases = [
|
||||
{"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
|
||||
{"date":"2026-01-15","amount":500.0,"sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"}, # currency missing → CZK
|
||||
{"date":"2026-02-10","amount":1234.56,"currency":"CZK","sender":"ABC SRO","vs":"","message":"FAKTURA 42","bank_id":"xyz"}, # mixed case → lowercased
|
||||
{"date":"2026-03-01","amount":-500.0,"currency":"CZK","sender":"refund","vs":"","message":"","bank_id":""}, # negative
|
||||
{"date":"2026-04-01","amount":0.0,"currency":"CZK","sender":"","vs":"","message":"","bank_id":""}, # zero amount
|
||||
{}, # empty dict — every field falls back to default
|
||||
]
|
||||
for c in cases:
|
||||
print(repr(c), "->", generate_sync_id(c))
|
||||
'
|
||||
```
|
||||
|
||||
Cases (one row per dict above), each asserting the exact 64-char hex
|
||||
digest the snippet prints. Cover:
|
||||
|
||||
- Happy path with all fields set.
|
||||
- `Currency: ""` → `"CZK"` default (parity with missing key).
|
||||
- Mixed-case sender/message → lowercased before hashing.
|
||||
- Negative amount.
|
||||
- Zero amount.
|
||||
- Zero-value `Transaction{}` — every field at Go zero, currency defaults
|
||||
to `"CZK"`, hash matches Python `generate_sync_id({})`.
|
||||
|
||||
### 2. `TestFormatAmount`
|
||||
|
||||
Pin the float formatter against Python's `str(float)`:
|
||||
|
||||
```sh
|
||||
PYTHONPATH=scripts:. python -c '
|
||||
for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
|
||||
print(repr(v), "->", repr(str(v)))
|
||||
'
|
||||
```
|
||||
|
||||
Table of `(float64, expected string)` pairs. Whole numbers must end in
|
||||
`.0`; existing decimal representations pass through unchanged;
|
||||
exponent-form floats (`1e16`, `1e-5`) keep their format.
|
||||
|
||||
## Files to create
|
||||
|
||||
- `go/internal/domain/synch/synch.go` — package, `Transaction`,
|
||||
`GenerateSyncID`, internal `formatAmount`.
|
||||
- `go/internal/domain/synch/synch_test.go` — `TestGenerateSyncID` +
|
||||
`TestFormatAmount`.
|
||||
|
||||
No existing Go files need editing.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
cd go && go test ./internal/domain/synch/...
|
||||
make go-lint
|
||||
make go-build # sanity: nothing else broke
|
||||
```
|
||||
|
||||
Plus run the two Python snippets in the Tests section and diff their
|
||||
output against the test tables to confirm parity.
|
||||
|
||||
## Out of scope (explicit non-goals)
|
||||
|
||||
- **Hooking into the Tier-1 parity runner.** That comes with M3.5
|
||||
(`-tags=parity` build constraint and `tests/fixtures/pure/`). M2.6
|
||||
ships with hand-written, Python-verified test tables — same approach
|
||||
used by M2.1–M2.5.
|
||||
- **A richer `Transaction` struct** covering ks/ss/note/sender_account.
|
||||
Those fields aren't part of the hash. M4.4 (Fio IO adapter) will
|
||||
decide whether to reuse `synch.Transaction` or define its own struct
|
||||
and convert at the boundary.
|
||||
- **Polymorphic input** (e.g. accepting a `map[string]any`). Python's
|
||||
duck-typing is a non-goal in Go.
|
||||
- **Any Python callsite migration.** `sync_fio_to_sheets.py` keeps using
|
||||
its own `generate_sync_id` until M4.7 ports the sync service.
|
||||
|
||||
## Progress tracker + changelog
|
||||
|
||||
After the commit lands:
|
||||
|
||||
- Tick `M2.6` in
|
||||
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||
with the commit SHA, mirroring the M2.5 entry style.
|
||||
- Add a `CHANGELOG.md` entry at top:
|
||||
`## YYYY-MM-DD HH:MM TZ — feat(go/M2.6): port domain/synch.GenerateSyncID`.
|
||||
|
||||
Branch: `feat/m2-6-synch-generate-sync-id` (per CLAUDE.md
|
||||
branch-per-feature workflow). Push, open MR via `tea pr create`, leave
|
||||
merge to the user.
|
||||
126
docs/plans/2026-05-06-1305-go-m2-7-2-9-matching.md
Normal file
126
docs/plans/2026-05-06-1305-go-m2-7-2-9-matching.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# M2.7 + M2.8 + M2.9 — Port `matching` package to Go
|
||||
|
||||
> On approval: copy this plan to `docs/plans/2026-05-06-1305-go-m2-7-2-9-matching.md` per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) plan-location convention.
|
||||
|
||||
## Context
|
||||
|
||||
The Go rewrite (tracked in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)) is in milestone M2 — porting pure-domain helpers leaf-first from Python to Go. M2.1 through M2.6 are complete (`czech.Normalize`, `czech.ParseMonthReferences`, `fees.CalculateFee`, `fees.CalculateJuniorFee`, `money.ParseCZK`, `synch.GenerateSyncID`).
|
||||
|
||||
M2.7, M2.8, and M2.9 cover three helpers from [scripts/match_payments.py](../../srv/personal/fuj-management/scripts/match_payments.py) that form a tight chain: `InferTransactionDetails` calls `MatchMembers` which calls `BuildNameVariants` and the same Sheets-serial date logic that `FormatDate` uses. The user requested they be done together because the dependency graph makes per-milestone commits awkward — `MatchMembers` would either reference an unexported helper not yet committed or commit dead code.
|
||||
|
||||
This unblocks M2.10 (`reconcile`, the load-bearing function) and M5 parity tests, since reconciliation consumes `InferTransactionDetails` output.
|
||||
|
||||
## Approach
|
||||
|
||||
**One commit, one branch, one MR.** Branch: `feat/m2-7-2-9-matching-package`. The three milestone checkboxes get ticked together on merge.
|
||||
|
||||
### Package layout
|
||||
|
||||
New package `go/internal/domain/matching/` mirroring the existing `go/internal/domain/{czech,fees,money,synch}` convention (one file per public symbol, tests alongside as `*_test.go`):
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `doc.go` | `// Package matching ports name/member matching from scripts/match_payments.py.` |
|
||||
| `name_variants.go` | `BuildNameVariants` + unexported `wordIn` helper (mirrors Python's `_word_in` co-location at [match_payments.py:60-62](../../srv/personal/fuj-management/scripts/match_payments.py#L60)) |
|
||||
| `match_members.go` | `Confidence` typed string + constants, `Match` struct, `MatchMembers` |
|
||||
| `infer.go` | `Transaction`, `InferredDetails`, `InferTransactionDetails` |
|
||||
| `format_date.go` | `FormatDate` |
|
||||
| `name_variants_test.go`, `match_members_test.go`, `infer_test.go`, `format_date_test.go` | table-driven tests, each with a top-of-file comment quoting the live Python one-liner used to verify expected values (mirrors [synch_test.go:7-20](../../srv/personal/fuj-management/go/internal/domain/synch/synch_test.go#L7)) |
|
||||
|
||||
### Public API
|
||||
|
||||
```go
|
||||
type Confidence string
|
||||
const (
|
||||
ConfidenceAuto Confidence = "auto"
|
||||
ConfidenceReview Confidence = "review"
|
||||
)
|
||||
type Match struct {
|
||||
Name string
|
||||
Confidence Confidence
|
||||
}
|
||||
|
||||
func BuildNameVariants(name string) []string
|
||||
func MatchMembers(text string, memberNames []string) []Match
|
||||
|
||||
type Transaction struct {
|
||||
Sender string
|
||||
Message string
|
||||
UserID string
|
||||
Date any // string | int | float64 — see "Parity concerns"
|
||||
}
|
||||
type InferredDetails struct {
|
||||
Members []Match
|
||||
Months []string
|
||||
SearchText string // matches Python's "search_text" key, not the misleading "matched_text" docstring
|
||||
}
|
||||
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails
|
||||
|
||||
func FormatDate(val any) string
|
||||
```
|
||||
|
||||
### Algorithms (port verbatim — these are the load-bearing details)
|
||||
|
||||
**`BuildNameVariants`** ([match_payments.py:33-57](../../srv/personal/fuj-management/scripts/match_payments.py#L33)): extract `(nickname)` regex, strip parens for `base`, normalize via `czech.Normalize`, append last + first when ≥2 parts, **filter <3 chars**. `variants[0]` must always be the full normalized base — `MatchMembers` relies on this.
|
||||
|
||||
**`MatchMembers`** ([match_payments.py:65-137](../../srv/personal/fuj-management/scripts/match_payments.py#L65)):
|
||||
1. **Exact short-circuit** ([:77-84](../../srv/personal/fuj-management/scripts/match_payments.py#L77)): if any member's `variants[0]` whole-word matches in `Normalize(text)`, return ONLY those `(name, auto)`. Prevents nickname `tov` matching inside `ottova`.
|
||||
2. Otherwise per-member first-match-wins: full-name substring → `\b first \b` AND `\b last \b` (any order) → `\b nickname \b` — each yields `auto` and continues.
|
||||
3. **Review tier** ([:113-129](../../srv/personal/fuj-management/scripts/match_payments.py#L113)): ≥2-part names → last name `len ≥ 4` AND not in `{"novak","novakova","prach"}` → review; else first name `len ≥ 3` → review. 1-part names → `len ≥ 4` → review.
|
||||
4. **Final filter** ([:131-137](../../srv/personal/fuj-management/scripts/match_payments.py#L131)): if ANY auto exists, drop ALL review. Two-pass — don't try to fuse with the loop.
|
||||
|
||||
**`InferTransactionDetails`** ([match_payments.py:144-184](../../srv/personal/fuj-management/scripts/match_payments.py#L144)): `search_text = sender + " " + message + " " + user_id`; month parse uses `message + " " + user_id` (excludes sender); fallback 1 retries members on sender alone; fallback 2 derives months from `tx.Date` (Sheets serial or `YYYY-MM-DD`).
|
||||
|
||||
**`FormatDate`** ([match_payments.py:187-206](../../srv/personal/fuj-management/scripts/match_payments.py#L187)): nil/empty → `""`; int/float → Sheets serial since 1899-12-30 formatted `YYYY-MM-DD`; pre-formatted `YYYY-MM-DD` (length 10, dashes at idx 4/7) → as-is; else `strings.TrimSpace(fmt.Sprint(v))`. **No raise on bad input** — parity contract.
|
||||
|
||||
## Parity concerns
|
||||
|
||||
- **RE2 `\b`**: Equivalent to Python `\b` on ASCII-folded input (`Normalize` strips diacritics + lowercases). Use `regexp.QuoteMeta` for `re.escape`.
|
||||
- **Sheets epoch**: 1899-12-30 (NOT 1900-01-01). `time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)`.
|
||||
- **Fractional serials**: Python `timedelta(days=44197.5)` adds 12 hours, then `.strftime("%Y-%m-%d")` discards time. To match exactly use `base.Add(time.Duration(val * 24 * float64(time.Hour)))` then `Format("2006-01-02")`. **Do NOT** use `base.AddDate(0, 0, int(val))` — that silently drops fractional days from real Sheets exports of timestamped cells.
|
||||
- **`Transaction.Date any`**: Python `tx["date"]` accepts int/float/string transparently. Sheets API returns serial dates as `float64` from JSON; FIO scraper returns `string`. `any` is the faithful port; type-switch inside `FormatDate` and the date fallback in `InferTransactionDetails`.
|
||||
- **`SearchText` vs `MatchedText`**: Python docstring says `matched_text`, code returns `"search_text"`. Port the code, not the docstring.
|
||||
- **Default year plumbing**: Go's `czech.ParseMonthReferences(text, defaultYear)` requires explicit year. Python defaults to 2026. Plumb `defaultYear` as the third arg to `InferTransactionDetails`.
|
||||
- **Empty slices not nil**: Python `match_members` returns `[]` when nothing matches; ensure Go returns `[]Match{}` not `nil` so consumers don't have to nil-check (matches `synch` package style).
|
||||
|
||||
## Tests
|
||||
|
||||
Port all 6 cases from [tests/test_match_members.py](../../srv/personal/fuj-management/tests/test_match_members.py) verbatim into `match_members_test.go` as one table-driven `TestMatchMembers`. Each row: `name`, `text`, `wantContains []string`, `wantExcludes []string`, `wantAllAuto bool`.
|
||||
|
||||
Add table cases for:
|
||||
- `BuildNameVariants` — docstring example `František Vrbík (Štrúdl)` → 4 variants; nickname filtered (len<3); single-part name; whitespace inside parens
|
||||
- `FormatDate` — `nil` → `""`, `""` → `""`, `int(44197)` → `"2020-12-31"`, `float64(44197.5)` → `"2020-12-31"`, `"2026-04-15"` → `"2026-04-15"`, `"garbage"` → `"garbage"`, `" 2026-04-15 "` → `"2026-04-15"`
|
||||
- `InferTransactionDetails` — members from search_text, members from sender fallback, months from date-string fallback, months from serial-date fallback, both-paths-fail returns empty slices
|
||||
|
||||
Verify expectations against live Python and quote the one-liner in a top-of-file comment, e.g.:
|
||||
|
||||
```
|
||||
PYTHONPATH=scripts:. python -c '
|
||||
from match_payments import format_date
|
||||
for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]: print(repr(format_date(v)))
|
||||
'
|
||||
```
|
||||
|
||||
## Critical files
|
||||
|
||||
- **Read for parity** — [scripts/match_payments.py:33-206](../../srv/personal/fuj-management/scripts/match_payments.py#L33), [tests/test_match_members.py](../../srv/personal/fuj-management/tests/test_match_members.py)
|
||||
- **Reuse** — `czech.Normalize` ([go/internal/domain/czech/normalize.go](../../srv/personal/fuj-management/go/internal/domain/czech/normalize.go#L15)), `czech.ParseMonthReferences` ([parse_month_references.go:61](../../srv/personal/fuj-management/go/internal/domain/czech/parse_month_references.go#L61))
|
||||
- **Mirror conventions** — [go/internal/domain/synch/synch.go](../../srv/personal/fuj-management/go/internal/domain/synch/synch.go), [go/internal/domain/synch/synch_test.go](../../srv/personal/fuj-management/go/internal/domain/synch/synch_test.go)
|
||||
- **New** — `go/internal/domain/matching/{doc,name_variants,match_members,infer,format_date}.go` + `*_test.go`
|
||||
|
||||
## Out of scope (M2.10 / M4 territory — DO NOT touch)
|
||||
|
||||
- `canonical_member_key` ([match_payments.py:20](../../srv/personal/fuj-management/scripts/match_payments.py#L20))
|
||||
- `reconcile`, `fetch_sheet_data`, `fetch_exceptions` — M2.10 / M4
|
||||
- Sheets/Drive/FIO I/O glue
|
||||
- Fixture capture (`tests/fixtures/pure/`) — M3.3 separately
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd go && make go-build` — clean build.
|
||||
2. `cd go && make go-test ./internal/domain/matching/...` — all table tests green.
|
||||
3. `cd go && make go-lint` — clean (govet, staticcheck, errcheck, gofumpt, unused).
|
||||
4. Spot-check: pick 2–3 random non-trivial cases (e.g. `MatchMembers` with mixed auto/review, `FormatDate(44197.5)`) and run the live Python one-liner from each test's comment block to confirm bytes match.
|
||||
5. Append CHANGELOG entry per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
|
||||
6. Tick M2.7, M2.8, M2.9 in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) with the merge SHA.
|
||||
7. Push branch, open MR via `tea pr create --title "feat(go): port matching helpers (M2.7-2.9)" --base main --head feat/m2-7-2-9-matching-package`, print URL, leave merge to user.
|
||||
129
docs/plans/2026-05-06-1626-infer-payments-junior-roster.md
Normal file
129
docs/plans/2026-05-06-1626-infer-payments-junior-roster.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Include junior members in payment inference roster
|
||||
|
||||
## Context
|
||||
|
||||
A bank payment from sender `JIŘÍ KUBÍK` with the message
|
||||
`Jáchym Kubík: 01/2026+03/2026+04/2026` is being inferred as
|
||||
`[?] Jáchym Hrušák (G)` instead of the obvious `Jáchym Kubík`, even though
|
||||
the message contains his exact full name.
|
||||
|
||||
**Root cause** (confirmed with the user): `Jáchym Kubík` is in the **junior**
|
||||
attendance sheet only — he does not appear on the main/adults sheet. But
|
||||
[scripts/infer_payments.py:101-102](scripts/infer_payments.py#L101-L102)
|
||||
builds `member_names` by calling `get_members_with_fees()`
|
||||
([scripts/attendance.py:170](scripts/attendance.py#L170)), which reads only
|
||||
`EXPORT_URL` (the adults sheet). Junior-only members are therefore invisible
|
||||
to the matcher.
|
||||
|
||||
With Kubík absent from `member_names`, the matcher in
|
||||
[scripts/match_payments.py:65](scripts/match_payments.py#L65) processes the
|
||||
combined text `jiri kubik jachym kubik: 01/2026+03/2026+04/2026` against an
|
||||
adults-only roster:
|
||||
|
||||
- The exact-full-name short-circuit (`match_payments.py:75-84`) finds nothing —
|
||||
no adult's full name is in the text.
|
||||
- Hrušák `(G)` is the only adult with first name `Jáchym`. He fails the
|
||||
auto-rules (his surname isn't in the text) but hits the partial-first-name
|
||||
review rule (`match_payments.py:123-125`) → returned as `("Jáchym Hrušák (G)",
|
||||
"review")`, rendered as `[?] Jáchym Hrušák (G)`.
|
||||
|
||||
The user's original framing — "exact match in message should win over
|
||||
everything" — is already implemented for any candidate that **is** in the
|
||||
roster (the May-04 short-circuit). The bug is upstream: the right candidate
|
||||
was never even considered.
|
||||
|
||||
**Goal:** make `infer_payments` consider junior members as candidates, so
|
||||
junior-only names like `Jáchym Kubík` get matched correctly.
|
||||
|
||||
## Approach
|
||||
|
||||
Single-file change in [scripts/infer_payments.py](scripts/infer_payments.py).
|
||||
|
||||
Replace the adults-only roster lookup with a union of the adult and junior
|
||||
rosters. `attendance.py` already exposes both:
|
||||
[`get_members_with_fees()`](scripts/attendance.py#L170) for adults (and tier-J
|
||||
juniors who train with adults) and
|
||||
[`get_junior_members_with_fees()`](scripts/attendance.py#L208) for everyone in
|
||||
the junior sheet.
|
||||
|
||||
### Edit at [scripts/infer_payments.py:15](scripts/infer_payments.py#L15)
|
||||
|
||||
```python
|
||||
from attendance import get_members_with_fees, get_junior_members_with_fees
|
||||
```
|
||||
|
||||
### Edit at [scripts/infer_payments.py:99-102](scripts/infer_payments.py#L99-L102)
|
||||
|
||||
```python
|
||||
print("Fetching member list for matching...")
|
||||
adult_members, _ = get_members_with_fees()
|
||||
junior_members, _ = get_junior_members_with_fees()
|
||||
|
||||
# Union rosters, preserving first-seen order, deduping by canonical key
|
||||
seen: set[str] = set()
|
||||
member_names: list[str] = []
|
||||
for m in adult_members + junior_members:
|
||||
name = m[0]
|
||||
key = canonical_member_key(name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
member_names.append(name)
|
||||
```
|
||||
|
||||
`canonical_member_key` already lives in
|
||||
[scripts/match_payments.py:20](scripts/match_payments.py#L20) — import it
|
||||
alongside `infer_transaction_details`. It normalizes diacritics/case/whitespace,
|
||||
so `"Maria Maco"` and `"Mária Maco"` collapse to the same key.
|
||||
|
||||
### Why downstream reconciliation still works
|
||||
|
||||
`reconcile()` is invoked twice per page — once with the adults roster
|
||||
([app.py:200](app.py#L200)) and once with the juniors roster
|
||||
([app.py:384](app.py#L384)). Each call resolves the `Person` cell against its
|
||||
own roster; a junior name resolves cleanly in the juniors call and lands in
|
||||
"unmatched" in the adults call. That's already the existing behavior for any
|
||||
junior payment manually entered into the `Person` column, so no further
|
||||
changes are needed.
|
||||
|
||||
### Files to modify
|
||||
|
||||
- [scripts/infer_payments.py](scripts/infer_payments.py) — only the
|
||||
import + roster construction. ~10-line change.
|
||||
|
||||
### Files to read for confidence (no edits)
|
||||
|
||||
- [scripts/attendance.py:208-289](scripts/attendance.py#L208-L289) —
|
||||
`get_junior_members_with_fees` returns `(name, tier, …)` tuples just like
|
||||
the adults version, so `m[0]` works for both.
|
||||
- [scripts/match_payments.py:65-137](scripts/match_payments.py#L65-L137) —
|
||||
`match_members` already handles the precedence the user wants (exact full-name
|
||||
short-circuit), so once Kubík is in `member_names`, the case will be auto-matched
|
||||
with no `[?]`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Manual sanity** — re-run inference on the offending row:
|
||||
- Clear `Person`/`Purpose` for the Kubík row in the payments sheet.
|
||||
- `make infer`.
|
||||
- Expect `Person = Jáchym Kubík`, `Purpose = 2026-01, 2026-03, 2026-04`,
|
||||
no `[?]`.
|
||||
|
||||
2. **Unit test** — extend
|
||||
[tests/test_match_members.py](tests/test_match_members.py) (or add a small
|
||||
`tests/test_infer_payments.py`) to assert that, given a roster that
|
||||
includes `Jáchym Hrušák (G)` and `Jáchym Kubík`, the message
|
||||
`Jáchym Kubík: 01/2026+03/2026+04/2026` resolves to
|
||||
`[("Jáchym Kubík", "auto")]` only. This is really a regression test for
|
||||
the May-04 short-circuit — the new behavior under test is just that
|
||||
`infer_payments` now feeds in juniors.
|
||||
|
||||
3. **Run the suite**: `make test`.
|
||||
|
||||
4. **Dashboard smoke** — `make web`, open `/payments`, confirm the row now
|
||||
shows the correct member; open `/juniors`, confirm the payment is
|
||||
credited to Kubík for the three months listed.
|
||||
|
||||
5. **Changelog** — once the user confirms the fix, append an entry to
|
||||
[CHANGELOG.md](CHANGELOG.md) per [CLAUDE.md](CLAUDE.md):
|
||||
`## YYYY-MM-DD HH:MM TZ — fix: include juniors in payment-inference roster`.
|
||||
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.
|
||||
261
docs/plans/2026-05-06-2111-go-m3-fixture-capture.md
Normal file
261
docs/plans/2026-05-06-2111-go-m3-fixture-capture.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# M3 — Fixture capture + characterization framework
|
||||
|
||||
> On approval: copy this plan to `docs/plans/2026-05-06-2111-go-m3-fixture-capture.md` per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) plan-location convention.
|
||||
|
||||
## Context
|
||||
|
||||
The Go rewrite (tracked in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)) finished M2.1–M2.12 — every pure-domain helper is ported and the `fuj fees` / `fuj reconcile` CLIs are wired. M3 closes the loop: it builds the **parity safety net** that proves Go output matches Python output for every ported function. Without it, M2 is "trust me", and the rewrite has no defensible cutover criterion.
|
||||
|
||||
M3 has three deliverables:
|
||||
|
||||
1. **A capture pipeline** (`scripts/capture_fixtures.py` + `scripts/scrub_fixtures.py`) that produces deterministic, PII-free JSON fixtures from the live Python implementations.
|
||||
2. **A fixture corpus** at [go/tests/fixtures/](../../srv/personal/fuj-management/go/tests/fixtures/) covering the 10 pure functions of M2 (M2.1–M2.9) plus 10 reconcile cases spanning every code path of `reconcile()` (M2.10).
|
||||
3. **A parity test runner** in [go/tests/parity/](../../srv/personal/fuj-management/go/tests/parity/) under `//go:build parity` that replays each fixture and asserts byte/value equality against the Go port.
|
||||
|
||||
User-confirmed scope decisions:
|
||||
- **Single MR** for all six sub-tasks (M3.1–M3.6) — they're tightly coupled; no half-state is committable.
|
||||
- **Type envelope only where it matters** — four fields (`generate_sync_id.tx.amount`, `parse_czk_amount.val`, `format_date.val`, `infer_transaction_details.tx.date`) use `{"type":..., "value":...}` to disambiguate int/float/none. Everything else uses raw JSON.
|
||||
- **Real seeds for `parse_month_references` and `match_members` only** — read curated message strings from `tmp/payments_transactions_cache.json`, scrub, ship. Other functions stay on handcrafted seeds.
|
||||
- **Plan committed at `docs/plans/2026-05-06-2111-go-m3-fixture-capture.md`** — same convention as every M-series predecessor.
|
||||
|
||||
## Branch + landing
|
||||
|
||||
- Branch: `feat/m3-fixture-capture`. Single MR via `tea pr create`. Tick M3.1–M3.6 on merge with the SHA.
|
||||
- No edits to existing Python or Go production code. M3 is purely additive: new scripts, new fixtures, new test files, new Makefile targets, README, CHANGELOG entry, plan archive, progress tracker tick.
|
||||
|
||||
## File layout
|
||||
|
||||
**Python (capture pipeline):**
|
||||
- [scripts/capture_fixtures.py](../../srv/personal/fuj-management/scripts/capture_fixtures.py) — dispatcher CLI; one entry per function via `--func`.
|
||||
- [scripts/scrub_fixtures.py](../../srv/personal/fuj-management/scripts/scrub_fixtures.py) — stdin→stdout deterministic bijection scrubber.
|
||||
- [scripts/_fixture_seeds.py](../../srv/personal/fuj-management/scripts/_fixture_seeds.py) — internal: handcrafted seeds keyed by `(func, case_id)`, plus the curated real-message extractor.
|
||||
|
||||
**Fixture corpus** (committed, PII-free):
|
||||
- [go/tests/fixtures/README.md](../../srv/personal/fuj-management/go/tests/fixtures/README.md) — refresh workflow + scrubbing audit guide.
|
||||
- `go/tests/fixtures/pure/<func>/<case>.json` — one directory per function (10 functions: `normalize`, `parse_month_references`, `calculate_fee`, `calculate_junior_fee`, `parse_czk_amount`, `generate_sync_id`, `build_name_variants`, `match_members`, `infer_transaction_details`, `format_date`).
|
||||
- `go/tests/fixtures/reconcile/<NN>_<case>.json` — 10 numbered reconcile cases.
|
||||
|
||||
**Go parity tests** (all under `//go:build parity`):
|
||||
- [go/tests/parity/parityio.go](../../srv/personal/fuj-management/go/tests/parity/parityio.go) — shared loader with generic `Case[I,O]` walker, type envelopes mirrored from §3, float tolerance helper.
|
||||
- [go/tests/parity/pure/<func>/<func>_parity_test.go](../../srv/personal/fuj-management/go/tests/parity/pure/) — one file per function, ~30 lines each.
|
||||
- [go/tests/parity/reconcile/reconcile_parity_test.go](../../srv/personal/fuj-management/go/tests/parity/reconcile/) — bespoke comparator using `math.Abs(got-want) <= 0.01` for `paid` floats, exact equality on int balances.
|
||||
|
||||
**Modified:**
|
||||
- [Makefile](../../srv/personal/fuj-management/Makefile) — append `go-parity`, `go-test-all`, `capture-fixtures` targets.
|
||||
- [CHANGELOG.md](../../srv/personal/fuj-management/CHANGELOG.md) — single entry at top.
|
||||
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — tick M3.1–M3.6 with SHA.
|
||||
|
||||
## Capture invocation interface
|
||||
|
||||
Two-stage pipeline (capture | scrub) so each stage is independently debuggable:
|
||||
|
||||
```bash
|
||||
python scripts/capture_fixtures.py --func <name> --case <id> --input-seed <id> \
|
||||
| python scripts/scrub_fixtures.py \
|
||||
> go/tests/fixtures/pure/<func>/<id>.json
|
||||
```
|
||||
|
||||
Capture flags:
|
||||
- `--func` — target function (`normalize`, `reconcile`, etc.).
|
||||
- `--case` — human-authored case ID, becomes the file stem. Never auto-generated (auto-IDs cause git churn).
|
||||
- `--input-seed <id>` — pull from `_fixture_seeds.py` registry (the default mode for handcrafted cases).
|
||||
- `--input-stdin` — read a single JSON `{"args":[...], "kwargs":{...}}` doc from stdin (used by the real-message extractor for `parse_month_references` / `match_members`).
|
||||
- `--all` — iterate every seed for one function, emit newline-delimited JSON to stdout. Used by the `make capture-fixtures` recipe.
|
||||
|
||||
Capture **never writes files**. Output goes to stdout; the caller redirects. The scrubber is always stdin→stdout. Both are pure transforms.
|
||||
|
||||
The `make capture-fixtures` target codifies the full refresh workflow. Humans read the target before they read the README.
|
||||
|
||||
## Fixture JSON shape (normative)
|
||||
|
||||
One JSON object per case:
|
||||
|
||||
```json
|
||||
{
|
||||
"case": "range_wrap_nov_to_jan",
|
||||
"func": "scripts.czech_utils.parse_month_references",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": { ... },
|
||||
"output": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`captured_at` is date-only — same-day re-runs produce byte-identical files. No git SHA, no hostname, no time component.
|
||||
|
||||
### Per-function input/output schemas
|
||||
|
||||
The schema is the **stable contract** between Python capture and Go consumption. Where Python returns heterogeneous types, the capture step pre-translates to the typed shape Go expects.
|
||||
|
||||
| Function | Input | Output |
|
||||
|---|---|---|
|
||||
| `normalize` | `{"text":"…"}` | `{"text":"…"}` |
|
||||
| `parse_month_references` | `{"text":"…","default_year":2026}` | `{"months":["2026-01",…]}` |
|
||||
| `calculate_fee` | `{"attendance_count":3,"month_key":"2026-02"}` | `{"fee":750}` |
|
||||
| `calculate_junior_fee` | `{"attendance_count":1,"month_key":"2026-02"}` | `{"value":0,"unknown":true}` (mirrors `fees.Expected{Value, Unknown}`) |
|
||||
| `parse_czk_amount` | `{"val":<envelope>}` | `{"amount":1500.0}` |
|
||||
| `generate_sync_id` | `{"tx":{"date":"…","amount":<envelope>,"currency":"CZK","sender":"…","vs":"…","message":"…","bank_id":"…"}}` | `{"sync_id":"<sha256-hex>"}` |
|
||||
| `_build_name_variants` | `{"name":"…"}` | `{"variants":["…"]}` |
|
||||
| `match_members` | `{"text":"…","member_names":["…"]}` | `{"matches":[{"name":"…","confidence":"auto"}]}` |
|
||||
| `infer_transaction_details` | `{"tx":{"sender":"…","message":"…","user_id":"…","date":<envelope>},"member_names":[…],"default_year":2026}` | `{"members":[…],"months":[…],"search_text":"…"}` |
|
||||
| `format_date` | `{"val":<envelope>}` | `{"date":"…"}` |
|
||||
|
||||
**Type envelope** (used in 4 fields above):
|
||||
|
||||
```json
|
||||
{"type":"int","value":750} // distinguishes 750 from 750.0
|
||||
{"type":"float","value":750.0}
|
||||
{"type":"string","value":"…"}
|
||||
{"type":"none"}
|
||||
```
|
||||
|
||||
The envelope is the answer to the `generate_sync_id` parity risk: Python's `str(750.0) == "750.0"` vs `str(750) == "750"` produces different SHA-256 inputs. JSON natively conflates these; the envelope round-trips them. Go's loader switches on `type` and constructs the matching native value before calling the port.
|
||||
|
||||
**`reconcile`** uses raw JSON for everything (its inputs are typed maps/slices already), with one nuance: the `Member.fees[month]` value can be an `int` or a `(fee, count)` tuple per [match_payments.py:339-340](../../srv/personal/fuj-management/scripts/match_payments.py#L339). Capture normalises both to `{"fee":int,"count":int}` so Go side has one shape.
|
||||
|
||||
## Scrubber strategy
|
||||
|
||||
`scrub_fixtures.py`: stdin → stdout, no state, no salt, no random. Deterministic plain SHA-256. Re-runs are idempotent. Trade-off acknowledged: an attacker with the script can mathematically reverse the mapping. That's fine — the scrubber's job is to keep PII out of git diffs and Claude transcripts, not to defend against an adversary with the source tree.
|
||||
|
||||
### Scramble whitelist (only these field keys are scrambled)
|
||||
|
||||
`name`, `member_names[]`, `person`, `sender`, `sender_account`, `account`, `vs`, `bank_id`, `user_id`, `note`. Plus a per-document name-substring sweep over `message` strings — applied **before** the field-key walk, because real names show up embedded in message text.
|
||||
|
||||
Everything else (dates, amounts, currency, `month_key`, `attendance_count`, `purpose`, `confidence`, `expected`, `paid`, `total_balance`, `fee`, all `YYYY-MM` keys, `match`/`matches` structure) is preserved verbatim. **Whitelist-of-scramble** (not blacklist-of-preserve): when a new field appears, it stays raw until someone explicitly adds it to the list. Fails safe.
|
||||
|
||||
### Scrambling functions
|
||||
|
||||
- **Names**: `Member_<8hex>` where `<8hex> = sha256(name).hexdigest()[:8]`. Same name → same pseudonym across the whole document and across all fixtures. Stable diffs.
|
||||
- **Account numbers** (`[0-9]+/[0-9]{4}`): scramble prefix and bank-suffix separately, preserving length and format.
|
||||
- **VS / bank_id / user_id**: digit-string-preserving hash to a same-length numeric token. Non-numeric input → `id_<8hex>`.
|
||||
- **Note**: replaced verbatim with `"<scrubbed>"`. Notes are never load-bearing for any test.
|
||||
- **Message** (free text): name-sweep applied; rest preserved. Corpus author spot-checks before commit. README §5 documents the audit grep.
|
||||
|
||||
## Reconcile fixtures (10 handcrafted cases)
|
||||
|
||||
All seeds live in `_fixture_seeds.py` as triples `(members, sorted_months, transactions, exceptions, default_year)`. Capture runs the live Python `reconcile()` and emits canonical JSON; scrubber is a no-op for handcrafted synthetic names but runs anyway for uniformity.
|
||||
|
||||
| File | Branch exercised |
|
||||
|---|---|
|
||||
| `01_greedy_exact.json` | Greedy: amount == sum(expected); zero credit. |
|
||||
| `02_greedy_overpayment_credit.json` | Greedy with overflow → credit. |
|
||||
| `03_proportional_remainder.json` | Underpayment across 3 months with non-integer split (last month absorbs float remainder per [match_payments.py:421+](../../srv/personal/fuj-management/scripts/match_payments.py#L421)). |
|
||||
| `04_even_split_prepayment.json` | All `expected == 0` → even-split fallback. |
|
||||
| `05_out_of_window_credit.json` | Month outside `sorted_months` → that share goes to credits, in-window proportional for the rest. |
|
||||
| `06_exception_override.json` | Exception entry overrides expected. |
|
||||
| `07_other_purpose_split.json` | `purpose="other:tournament"` with two members. |
|
||||
| `08_junior_question_mark.json` | Junior with attendance count 1 → `Expected{Unknown:true}`; reconcile reads it as 0 expected. |
|
||||
| `09_multiperson_multimonth.json` | `person="Alice, Bob", purpose="2026-01, 2026-02"` → 2x2 fan-out: even-split-by-people then proportional-by-month. |
|
||||
| `10_unmatched.json` | Empty `person`, garbage message → goes to `unmatched`. |
|
||||
|
||||
The seed registry is the **single source of truth** for these inputs. If Python behaviour drifts intentionally, fixtures regenerate cleanly via `make capture-fixtures`.
|
||||
|
||||
## Real-data seeds (for `parse_month_references` and `match_members` only)
|
||||
|
||||
`_fixture_seeds.py` reads `tmp/payments_transactions_cache.json` (already gitignored) and selects:
|
||||
|
||||
- **`parse_month_references`**: ~15 distinct messages exercising the 45 Czech month declensions, range wraps (`"prosinec-leden"`), year inference, and the `m >= 10 → previous year` heuristic. Selection done once interactively, the chosen indices hardcoded into `_fixture_seeds.py` so re-runs are deterministic. Messages flow through capture (which calls `parse_month_references(msg, default_year=2026)`) then scrubber (name-sweep against the live member roster).
|
||||
- **`match_members`**: ~10 distinct `(message, member_names)` pairs exercising auto vs review confidence, common-surname filter, exact-short-circuit. Same pipeline.
|
||||
|
||||
**Out of scope for real seeds**: `normalize`, `_build_name_variants`, `reconcile`. These either don't benefit from real data (synthetic exhaustively covers `normalize`, `_build_name_variants`) or have surgical-input requirements that real data can't reliably hit (`reconcile`'s 10 branches).
|
||||
|
||||
## Go parity-test layout
|
||||
|
||||
One file per function, one Go package per function, mirroring the fixture tree. Each file is short (~30 lines):
|
||||
|
||||
```go
|
||||
//go:build parity
|
||||
|
||||
package normalize_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"fuj-management/go/tests/parity"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/normalize",
|
||||
func(in parity.NormalizeIn) parity.NormalizeOut {
|
||||
return parity.NormalizeOut{Text: czech.Normalize(in.Text)}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The shared [go/tests/parity/parityio.go](../../srv/personal/fuj-management/go/tests/parity/parityio.go) (also `//go:build parity`) provides:
|
||||
|
||||
- `Case[I, O any]` generic loader: walks a fixture directory, decodes each `.json`, returns `(name, input, want)` triples.
|
||||
- `RunAll[I, O any](t, dir, fn func(I) O)`: invokes `fn`, compares against `want` with `reflect.DeepEqual` (sorted-slice normalisation for the few sets-cast-to-lists Python returns); for floats uses `math.Abs(got-want) <= 0.01`.
|
||||
- One typed `<Func>In` / `<Func>Out` struct pair per function (10 pairs), mirroring §3's JSON shape exactly. Envelope decoder helpers (`AmountEnvelope`, `ValueEnvelope`) live here.
|
||||
|
||||
**Reconcile is bespoke** — `reconcile/reconcile_parity_test.go` doesn't use `RunAll` because it needs cell-by-cell tolerant float compare across nested maps. It walks the fixture dir directly.
|
||||
|
||||
**Why one-file-per-function** (instead of an umbrella runner): each function lives in a different domain package, so tests must `import` a different package; an umbrella would obscure which package is being checked. Split also enables `go test -tags=parity ./tests/parity/pure/normalize/` to iterate on a single port.
|
||||
|
||||
**Why a separate test tree** (instead of co-located parity tests): the M2 unit tests are co-located by convention (e.g. [go/internal/domain/czech/normalize_test.go](../../srv/personal/fuj-management/go/internal/domain/czech/normalize_test.go)). The progress tracker explicitly says fixtures live at `go/tests/fixtures/` and the gate is `go test -tags=parity ./tests/parity/pure/...`. Co-location would scatter fixtures across packages — messy. Separate tree wins.
|
||||
|
||||
## Build tag + Makefile
|
||||
|
||||
Every parity test file starts with `//go:build parity`. Default `make go-test` excludes them; `make go-parity` runs them:
|
||||
|
||||
```makefile
|
||||
go-parity:
|
||||
cd $(GO_SRC) && go test -tags=parity ./tests/parity/...
|
||||
|
||||
go-test-all: go-test go-parity
|
||||
|
||||
capture-fixtures:
|
||||
@bash scripts/capture_all_fixtures.sh # invokes capture | scrub for every seed
|
||||
```
|
||||
|
||||
Parity is **not** folded into default `go-test`: keeps the M2 unit-test loop fast, and a missing-fixture failure shouldn't block routine work. CI runs both targets independently so a parity break is a distinct red signal from a unit-test break.
|
||||
|
||||
## README content (`go/tests/fixtures/README.md`)
|
||||
|
||||
Six sections, ~120 lines:
|
||||
|
||||
1. **What's in this tree** — directory map; one line per fixture function explaining what it validates.
|
||||
2. **Fixture format** — link to schemas in §3; worked example for `parse_month_references` and one for `reconcile`.
|
||||
3. **Refresh workflow** — `make capture-fixtures` regenerates everything; single-file recipe for incremental updates. Always diff before committing.
|
||||
4. **When to refresh** — bullet list (schema change, new Czech declension, new fee tier, new reconcile branch). **Do not refresh to "fix" a parity failure** without first proving the Python behaviour is the intended one.
|
||||
5. **Verifying scrubbing** — `git diff` should show only `Member_<hex>`-shaped names, `<scrubbed>` notes, structurally-preserved account/VS digits. Audit grep: `git ls-files go/tests/fixtures | xargs grep -l '<your real name>'` should return zero before commit.
|
||||
6. **Adding a new fixture** — three steps (add to `_fixture_seeds.py`, run capture, add `In/Out` Go struct fields if needed).
|
||||
|
||||
## Parity concerns
|
||||
|
||||
- **Float arithmetic in reconcile proportional phase**: ordering-sensitive, may diverge between Python and Go due to FMA. Tolerance `0.01` already in [go/internal/domain/reconcile/reconcile_test.go](../../srv/personal/fuj-management/go/internal/domain/reconcile/reconcile_test.go); parity uses the same tolerance.
|
||||
- **Sync-ID float-vs-int stringification**: handled by the envelope (§3). Capture two paired cases per amount value (`amount_750_int.json`, `amount_750_float.json`) so any Go-side conflation surfaces immediately.
|
||||
- **NFKD edge cases**: capture set must include rare characters from real names. The handcrafted `normalize` seeds enumerate every distinct character observed in the live member roster (extracted once from `tmp/attendance_regular_cache.json`, hardcoded into `_fixture_seeds.py` as a single-character-per-case sweep).
|
||||
- **Czech month declensions**: the real-message seeds for `parse_month_references` cover the wild; handcrafted seeds cover the corner cases (`prosinec-leden` wrap, `m >= 10` heuristic).
|
||||
- **Insertion-order determinism in `reconcile`**: Python 3.7+ dict iteration is insertion-ordered; the seed registry preserves order. Go side iterates `sortedMonths` slice explicitly; the parity test verifies this.
|
||||
- **`infer_transaction_details` default_year**: Python signature defaults to 2026; capture passes `default_year` as an explicit input. Go side reads it from the fixture.
|
||||
|
||||
## 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.
|
||||
- Tier-2 JSON-API parity (`cmd/parity/main.go`) — M5.4.
|
||||
- Any change to existing Python code (capture is read-only against the production scripts).
|
||||
- Any change to existing Go production code under `go/internal/`.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make go-build` — clean build (parity tests excluded by default tag).
|
||||
2. `make go-test` — all M2 unit tests still green; no parity test runs.
|
||||
3. `make go-parity` — every fixture in `go/tests/fixtures/pure/` and `go/tests/fixtures/reconcile/` deserialises and passes its parity assertion.
|
||||
4. `make go-lint` — clean (parity test files lint-clean under `-tags=parity` since `golangci-lint` honours build tags via `.golangci.yml`).
|
||||
5. **Capture round-trip**: pick one fixture (e.g. `parse_month_references/range_wrap_nov_to_jan.json`), regenerate via `python scripts/capture_fixtures.py --func parse_month_references --case range_wrap_nov_to_jan --input-seed range_wrap_nov_to_jan | python scripts/scrub_fixtures.py`, confirm byte-identical to the committed file.
|
||||
6. **Scrubbing audit**: run the README §5 grep against any name from the live roster — zero hits.
|
||||
7. **Reconcile branch coverage**: read each of the 10 reconcile fixture files, confirm the `output` field shows the expected branch (e.g. `02_greedy_overpayment_credit.json` has a non-zero `credits` entry; `04_even_split_prepayment.json` has equal `paid` across all months).
|
||||
8. Append CHANGELOG entry per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
|
||||
9. Tick M3.1–M3.6 in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) with the merge SHA. Update the M3 milestone summary line if M3 is now fully closed.
|
||||
10. Push branch, open MR via `tea pr create --title "feat(go): fixture capture + characterization framework (M3)" --base main --head feat/m3-fixture-capture`, print URL, leave merge to user.
|
||||
|
||||
## Critical files
|
||||
|
||||
- **Read for parity** — [scripts/czech_utils.py:22](../../srv/personal/fuj-management/scripts/czech_utils.py#L22), [scripts/czech_utils.py:28](../../srv/personal/fuj-management/scripts/czech_utils.py#L28), [scripts/attendance.py:91](../../srv/personal/fuj-management/scripts/attendance.py#L91), [scripts/attendance.py:100](../../srv/personal/fuj-management/scripts/attendance.py#L100), [scripts/infer_payments.py:17](../../srv/personal/fuj-management/scripts/infer_payments.py#L17), [scripts/sync_fio_to_sheets.py:62](../../srv/personal/fuj-management/scripts/sync_fio_to_sheets.py#L62), [scripts/match_payments.py:33](../../srv/personal/fuj-management/scripts/match_payments.py#L33), [scripts/match_payments.py:65](../../srv/personal/fuj-management/scripts/match_payments.py#L65), [scripts/match_payments.py:144](../../srv/personal/fuj-management/scripts/match_payments.py#L144), [scripts/match_payments.py:187](../../srv/personal/fuj-management/scripts/match_payments.py#L187), [scripts/match_payments.py:304](../../srv/personal/fuj-management/scripts/match_payments.py#L304).
|
||||
- **Reuse** — `domain/czech.{Normalize, ParseMonthReferences}`, `domain/fees.{CalculateFee, CalculateJuniorFee, Expected}`, `domain/money.ParseCZK`, `domain/synch.GenerateSyncID`, `domain/matching.{BuildNameVariants, MatchMembers, InferTransactionDetails, FormatDate}`, `domain/reconcile.{Member, Transaction, ExceptionKey, Exception, Result, Reconcile}`.
|
||||
- **Mirror conventions** — package layout from [go/internal/domain/matching/](../../srv/personal/fuj-management/go/internal/domain/matching/) (one symbol per file, top-of-test provenance comments, `t.Parallel()`, `// [Go]` markers for Go-only cases).
|
||||
- **New** — `scripts/{capture_fixtures,scrub_fixtures,_fixture_seeds}.py`; `go/tests/fixtures/README.md` + the corpus; `go/tests/parity/parityio.go` + 10 parity test files + 1 reconcile parity test file.
|
||||
- **Modify** — `Makefile` (3 new targets), `CHANGELOG.md` (1 entry), `docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md` (tick M3.1–M3.6).
|
||||
@@ -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]`)
|
||||
}
|
||||
|
||||
2
go/internal/domain/matching/doc.go
Normal file
2
go/internal/domain/matching/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package matching ports name/member matching from scripts/match_payments.py.
|
||||
package matching
|
||||
41
go/internal/domain/matching/format_date.go
Normal file
41
go/internal/domain/matching/format_date.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package matching
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var sheetsEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// FormatDate normalizes a date value from Google Sheets.
|
||||
//
|
||||
// Accepts nil, empty string, int/float64 Sheets serial days since 1899-12-30,
|
||||
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
|
||||
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
|
||||
//
|
||||
// Ports scripts/match_payments.py format_date.
|
||||
func FormatDate(val any) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
return sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour))).Format("2006-01-02")
|
||||
case int64:
|
||||
return sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour))).Format("2006-01-02")
|
||||
case float64:
|
||||
return sheetsEpoch.Add(time.Duration(v * 24 * float64(time.Hour))).Format("2006-01-02")
|
||||
case string:
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if len(s) == 10 && s[4] == '-' && s[7] == '-' {
|
||||
return s
|
||||
}
|
||||
return s
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
49
go/internal/domain/matching/format_date_test.go
Normal file
49
go/internal/domain/matching/format_date_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package matching
|
||||
|
||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c '
|
||||
// from match_payments import format_date
|
||||
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
|
||||
// print(repr(format_date(v)))
|
||||
// '
|
||||
//
|
||||
// Output:
|
||||
//
|
||||
// ''
|
||||
// ''
|
||||
// '2021-01-01'
|
||||
// '2021-01-01'
|
||||
// '2026-04-15'
|
||||
// 'garbage'
|
||||
// '2026-04-15'
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input any
|
||||
want string
|
||||
}{
|
||||
{name: "nil", input: nil, want: ""},
|
||||
{name: "empty string", input: "", want: ""},
|
||||
{name: "serial int", input: int(44197), want: "2021-01-01"},
|
||||
{name: "serial float fractional", input: float64(44197.5), want: "2021-01-01"},
|
||||
{name: "already formatted", input: "2026-04-15", want: "2026-04-15"},
|
||||
{name: "garbage string", input: "garbage", want: "garbage"},
|
||||
{name: "padded date string trimmed", input: " 2026-04-15 ", want: "2026-04-15"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := FormatDate(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("FormatDate(%v) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
89
go/internal/domain/matching/infer.go
Normal file
89
go/internal/domain/matching/infer.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package matching
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Transaction is the subset of a payment row used by InferTransactionDetails.
|
||||
// Date accepts string ("YYYY-MM-DD"), float64 (Sheets serial), or int — matching
|
||||
// the heterogeneous types returned by the Sheets API and the FIO scraper.
|
||||
type Transaction struct {
|
||||
Sender string
|
||||
Message string
|
||||
UserID string
|
||||
Date any
|
||||
}
|
||||
|
||||
// InferredDetails is the result of InferTransactionDetails.
|
||||
type InferredDetails struct {
|
||||
Members []Match
|
||||
Months []string
|
||||
SearchText string
|
||||
}
|
||||
|
||||
// InferTransactionDetails infers which member(s) and month(s) a transaction belongs to.
|
||||
//
|
||||
// Search text for member matching: sender + message + user_id.
|
||||
// Month search text: message + user_id only (sender excluded, matching Python).
|
||||
// Fallback 1: if no members found, retry match on sender alone.
|
||||
// Fallback 2: if no months found, derive from tx.Date (Sheets serial or YYYY-MM-DD).
|
||||
//
|
||||
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
|
||||
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
|
||||
//
|
||||
// Ports scripts/match_payments.py infer_transaction_details.
|
||||
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
|
||||
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
||||
|
||||
members := MatchMembers(searchText, memberNames)
|
||||
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
|
||||
|
||||
if len(members) == 0 {
|
||||
members = MatchMembers(tx.Sender, memberNames)
|
||||
}
|
||||
|
||||
if len(months) == 0 && tx.Date != nil && tx.Date != "" {
|
||||
if ym := inferMonthFromDate(tx.Date); ym != "" {
|
||||
months = []string{ym}
|
||||
}
|
||||
}
|
||||
|
||||
if months == nil {
|
||||
months = []string{}
|
||||
}
|
||||
|
||||
return InferredDetails{
|
||||
Members: members,
|
||||
Months: months,
|
||||
SearchText: searchText,
|
||||
}
|
||||
}
|
||||
|
||||
// inferMonthFromDate converts a date value to "YYYY-MM" for the month fallback.
|
||||
// Returns "" on any error, matching Python's bare except pass.
|
||||
func inferMonthFromDate(val any) string {
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
dt := sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour)))
|
||||
return dt.Format("2006-01")
|
||||
case int64:
|
||||
dt := sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour)))
|
||||
return dt.Format("2006-01")
|
||||
case float64:
|
||||
dt := sheetsEpoch.Add(time.Duration(v * 24 * float64(time.Hour)))
|
||||
return dt.Format("2006-01")
|
||||
case string:
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
dt, err := time.Parse("2006-01-02", v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return dt.Format("2006-01")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
108
go/internal/domain/matching/infer_test.go
Normal file
108
go/internal/domain/matching/infer_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package matching
|
||||
|
||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 << 'EOF'
|
||||
// from match_payments import infer_transaction_details
|
||||
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
|
||||
// cases = [
|
||||
// ({"sender":"Tomas Nemecek","message":"clenske 04/2026","user_id":"","date":"2026-04-15"}, "full match"),
|
||||
// ({"sender":"Tomas Nemecek","message":"","user_id":"","date":"2026-04-15"}, "sender fallback month"),
|
||||
// ({"sender":"Jana Novakova","message":"","user_id":"","date":44197}, "serial int date"),
|
||||
// ({"sender":"neznamy","message":"","user_id":"","date":""}, "no match"),
|
||||
// ({"sender":"Tomas Nemecek","message":"","user_id":"","date":44197.5}, "serial float date"),
|
||||
// ]
|
||||
// for tx, label in cases:
|
||||
// r = infer_transaction_details(tx, MEMBERS)
|
||||
// print(label + ": members=" + repr(r["members"]) + " months=" + repr(r["months"]) + " search_text=" + repr(r["search_text"]))
|
||||
// EOF
|
||||
//
|
||||
// Output:
|
||||
//
|
||||
// full match: members=[('Tomáš Němeček (Tov)', 'auto')] months=['2026-04'] search_text='Tomas Nemecek clenske 04/2026 '
|
||||
// sender fallback month: members=[('Tomáš Němeček (Tov)', 'auto')] months=['2026-04'] search_text='Tomas Nemecek '
|
||||
// serial int date: members=[('Jana Nováková', 'auto')] months=['2021-01'] search_text='Jana Novakova '
|
||||
// no match: members=[] months=[] search_text='neznamy '
|
||||
// serial float date: members=[('Tomáš Němeček (Tov)', 'auto')] months=['2021-01'] search_text='Tomas Nemecek '
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var inferMembers = []string{"Tomáš Němeček (Tov)", "Jana Nováková"}
|
||||
|
||||
func TestInferTransactionDetails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tx Transaction
|
||||
defaultYear int
|
||||
wantMembers []Match
|
||||
wantMonths []string
|
||||
wantSearchText string
|
||||
}{
|
||||
{
|
||||
name: "full match — members and months from search text",
|
||||
tx: Transaction{Sender: "Tomas Nemecek", Message: "clenske 04/2026", UserID: "", Date: "2026-04-15"},
|
||||
defaultYear: 2026,
|
||||
wantMembers: []Match{{Name: "Tomáš Němeček (Tov)", Confidence: ConfidenceAuto}},
|
||||
wantMonths: []string{"2026-04"},
|
||||
// Python: sender + " " + message + " " + user_id (no trim)
|
||||
wantSearchText: "Tomas Nemecek clenske 04/2026 ",
|
||||
},
|
||||
{
|
||||
// months not in message → fall back to date string
|
||||
name: "months fall back to date string",
|
||||
tx: Transaction{Sender: "Tomas Nemecek", Message: "", UserID: "", Date: "2026-04-15"},
|
||||
defaultYear: 2026,
|
||||
wantMembers: []Match{{Name: "Tomáš Němeček (Tov)", Confidence: ConfidenceAuto}},
|
||||
wantMonths: []string{"2026-04"},
|
||||
wantSearchText: "Tomas Nemecek ",
|
||||
},
|
||||
{
|
||||
// months fall back to Sheets serial int date
|
||||
name: "months fall back to serial int date",
|
||||
tx: Transaction{Sender: "Jana Novakova", Message: "", UserID: "", Date: int(44197)},
|
||||
defaultYear: 2026,
|
||||
wantMembers: []Match{{Name: "Jana Nováková", Confidence: ConfidenceAuto}},
|
||||
wantMonths: []string{"2021-01"},
|
||||
wantSearchText: "Jana Novakova ",
|
||||
},
|
||||
{
|
||||
// months fall back to Sheets serial float64 date
|
||||
name: "months fall back to serial float date",
|
||||
tx: Transaction{Sender: "Tomas Nemecek", Message: "", UserID: "", Date: float64(44197.5)},
|
||||
defaultYear: 2026,
|
||||
wantMembers: []Match{{Name: "Tomáš Němeček (Tov)", Confidence: ConfidenceAuto}},
|
||||
wantMonths: []string{"2021-01"},
|
||||
wantSearchText: "Tomas Nemecek ",
|
||||
},
|
||||
{
|
||||
name: "no match — both slices empty not nil",
|
||||
tx: Transaction{Sender: "neznamy", Message: "", UserID: "", Date: ""},
|
||||
defaultYear: 2026,
|
||||
wantMembers: []Match{},
|
||||
wantMonths: []string{},
|
||||
wantSearchText: "neznamy ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := InferTransactionDetails(tc.tx, inferMembers, tc.defaultYear)
|
||||
|
||||
if !reflect.DeepEqual(got.Members, tc.wantMembers) {
|
||||
t.Errorf("Members\n got %v\n want %v", got.Members, tc.wantMembers)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Months, tc.wantMonths) {
|
||||
t.Errorf("Months\n got %v\n want %v", got.Months, tc.wantMonths)
|
||||
}
|
||||
if got.SearchText != tc.wantSearchText {
|
||||
t.Errorf("SearchText\n got %q\n want %q", got.SearchText, tc.wantSearchText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
131
go/internal/domain/matching/match_members.go
Normal file
131
go/internal/domain/matching/match_members.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package matching
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Confidence indicates how certain a member match is.
|
||||
type Confidence string
|
||||
|
||||
const (
|
||||
ConfidenceAuto Confidence = "auto"
|
||||
ConfidenceReview Confidence = "review"
|
||||
)
|
||||
|
||||
// Match pairs a canonical member name with the confidence of the match.
|
||||
type Match struct {
|
||||
Name string
|
||||
Confidence Confidence
|
||||
}
|
||||
|
||||
var commonSurnames = map[string]bool{
|
||||
"novak": true,
|
||||
"novakova": true,
|
||||
"prach": true,
|
||||
}
|
||||
|
||||
// MatchMembers finds members mentioned in text and returns them with a
|
||||
// confidence level of "auto" (reliable) or "review" (needs human verification).
|
||||
//
|
||||
// Algorithm (ported verbatim from scripts/match_payments.py match_members):
|
||||
// 1. Exact short-circuit: if any member's full normalized name appears as whole
|
||||
// words in normalize(text), return ONLY those matches as auto. This prevents
|
||||
// nickname "tov" from matching inside surname "ottova".
|
||||
// 2. Per-member first-match-wins: full-name substring → first+last both present
|
||||
// (any order) → nickname whole-word. Each yields auto.
|
||||
// 3. Review tier: last name (len≥4, not a common surname) → first name (len≥3)
|
||||
// → single-part name (len≥4). Each yields review.
|
||||
// 4. Final filter: if any auto exists, drop all review.
|
||||
func MatchMembers(text string, memberNames []string) []Match {
|
||||
normalizedText := czech.Normalize(text)
|
||||
|
||||
// Pass 1: exact short-circuit
|
||||
var exactMatches []Match
|
||||
for _, name := range memberNames {
|
||||
variants := BuildNameVariants(name)
|
||||
if len(variants) == 0 {
|
||||
continue
|
||||
}
|
||||
fullName := variants[0]
|
||||
if fullName != "" && wordIn(fullName, normalizedText) {
|
||||
exactMatches = append(exactMatches, Match{Name: name, Confidence: ConfidenceAuto})
|
||||
}
|
||||
}
|
||||
if len(exactMatches) > 0 {
|
||||
return exactMatches
|
||||
}
|
||||
|
||||
// Pass 2 + 3: fuzzy matching
|
||||
var matches []Match
|
||||
for _, name := range memberNames {
|
||||
variants := BuildNameVariants(name)
|
||||
fullName := ""
|
||||
if len(variants) > 0 {
|
||||
fullName = variants[0]
|
||||
}
|
||||
parts := strings.Fields(fullName)
|
||||
|
||||
// Auto tier
|
||||
if fullName != "" && strings.Contains(normalizedText, fullName) {
|
||||
matches = append(matches, Match{Name: name, Confidence: ConfidenceAuto})
|
||||
continue
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
if wordIn(parts[0], normalizedText) && wordIn(parts[len(parts)-1], normalizedText) {
|
||||
matches = append(matches, Match{Name: name, Confidence: ConfidenceAuto})
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Nickname check
|
||||
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
||||
nick := czech.Normalize(m[1])
|
||||
if nick != "" && wordIn(nick, normalizedText) {
|
||||
matches = append(matches, Match{Name: name, Confidence: ConfidenceAuto})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Review tier
|
||||
if len(parts) >= 2 {
|
||||
lastName := parts[len(parts)-1]
|
||||
firstName := parts[0]
|
||||
if len(lastName) >= 4 && !commonSurnames[lastName] && wordIn(lastName, normalizedText) {
|
||||
matches = append(matches, Match{Name: name, Confidence: ConfidenceReview})
|
||||
continue
|
||||
}
|
||||
if len(firstName) >= 3 && wordIn(firstName, normalizedText) {
|
||||
matches = append(matches, Match{Name: name, Confidence: ConfidenceReview})
|
||||
continue
|
||||
}
|
||||
} else if len(parts) == 1 {
|
||||
if len(parts[0]) >= 4 && wordIn(parts[0], normalizedText) {
|
||||
matches = append(matches, Match{Name: name, Confidence: ConfidenceReview})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final filter: drop review if any auto exists
|
||||
hasAuto := false
|
||||
for _, m := range matches {
|
||||
if m.Confidence == ConfidenceAuto {
|
||||
hasAuto = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasAuto {
|
||||
filtered := matches[:0]
|
||||
for _, m := range matches {
|
||||
if m.Confidence == ConfidenceAuto {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
if matches == nil {
|
||||
return []Match{}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
156
go/internal/domain/matching/match_members_test.go
Normal file
156
go/internal/domain/matching/match_members_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package matching
|
||||
|
||||
// Expected values verified against scripts/match_payments.py and
|
||||
// tests/test_match_members.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c '
|
||||
// from match_payments import match_members
|
||||
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
|
||||
// cases = [
|
||||
// ("Henrietta Ottová (Heny): 04/2026", "full name guard"),
|
||||
// ("platba ottova 04/2026", "ottova surname"),
|
||||
// ("Henrietta Ottová a Tomáš Němeček 04/2026", "two full names"),
|
||||
// ("Tov platba 04/2026", "nickname alone"),
|
||||
// ("Henrietta Ottova 04/2026", "no diacritics"),
|
||||
// ("Platba od Nemeček Tomas 04/2026", "reversed first+last"),
|
||||
// ("vrbik clenske", "last name only review"),
|
||||
// ("jana platba", "first name review"),
|
||||
// ("neznamy platebce", "no match"),
|
||||
// ]
|
||||
// for text, label in cases: print(label + ":", match_members(text, MEMBERS))
|
||||
// '
|
||||
//
|
||||
// Output:
|
||||
//
|
||||
// full name guard: [('Henrietta Ottová', 'auto')]
|
||||
// ottova surname: [('Henrietta Ottová', 'review')]
|
||||
// two full names: [('Henrietta Ottová', 'auto'), ('Tomáš Němeček (Tov)', 'auto')]
|
||||
// nickname alone: [('Tomáš Němeček (Tov)', 'auto')]
|
||||
// no diacritics: [('Henrietta Ottová', 'auto')]
|
||||
// reversed first+last: [('Tomáš Němeček (Tov)', 'auto')]
|
||||
// last name only review: [('František Vrbík (Štrúdl)', 'review')]
|
||||
// first name review: [('Jana Nováková', 'review')]
|
||||
// no match: []
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testMembers = []string{
|
||||
"Henrietta Ottová",
|
||||
"Tomáš Němeček (Tov)",
|
||||
"František Vrbík (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
}
|
||||
|
||||
func TestMatchMembers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
wantContains []string
|
||||
wantExcludes []string
|
||||
wantAllAuto bool
|
||||
}{
|
||||
{
|
||||
// Short-circuit: full name matches → "tov" inside "ottova" must NOT fire
|
||||
name: "full name in message returns only that member",
|
||||
text: "Henrietta Ottová (Heny): 04/2026",
|
||||
wantContains: []string{"Henrietta Ottová"},
|
||||
wantExcludes: []string{"Tomáš Němeček (Tov)"},
|
||||
wantAllAuto: true,
|
||||
},
|
||||
{
|
||||
// "tov" is a substring of "ottova" — nickname must not match inside a surname
|
||||
name: "nickname tov not matched inside ottova",
|
||||
text: "platba ottova 04/2026",
|
||||
wantExcludes: []string{"Tomáš Němeček (Tov)"},
|
||||
wantAllAuto: false,
|
||||
},
|
||||
{
|
||||
name: "two full names both auto",
|
||||
text: "Henrietta Ottová a Tomáš Němeček 04/2026",
|
||||
wantContains: []string{"Henrietta Ottová", "Tomáš Němeček (Tov)"},
|
||||
wantAllAuto: true,
|
||||
},
|
||||
{
|
||||
name: "nickname alone matches correctly",
|
||||
text: "Tov platba 04/2026",
|
||||
wantContains: []string{"Tomáš Němeček (Tov)"},
|
||||
wantAllAuto: true,
|
||||
},
|
||||
{
|
||||
name: "full name without diacritics auto",
|
||||
text: "Henrietta Ottova 04/2026",
|
||||
wantContains: []string{"Henrietta Ottová"},
|
||||
wantExcludes: []string{"Tomáš Němeček (Tov)"},
|
||||
wantAllAuto: true,
|
||||
},
|
||||
{
|
||||
name: "first and last name reversed auto",
|
||||
text: "Platba od Nemeček Tomas 04/2026",
|
||||
wantContains: []string{"Tomáš Němeček (Tov)"},
|
||||
wantAllAuto: true,
|
||||
},
|
||||
{
|
||||
// Last name alone (len≥4, not a common surname) → review confidence
|
||||
name: "last name only yields review",
|
||||
text: "vrbik clenske",
|
||||
wantContains: []string{"František Vrbík (Štrúdl)"},
|
||||
wantAllAuto: false,
|
||||
},
|
||||
{
|
||||
// First name alone (len≥3) → review confidence
|
||||
name: "first name only yields review",
|
||||
text: "jana platba",
|
||||
wantContains: []string{"Jana Nováková"},
|
||||
wantAllAuto: false,
|
||||
},
|
||||
{
|
||||
name: "no match returns empty slice",
|
||||
text: "neznamy platebce",
|
||||
wantContains: nil,
|
||||
wantAllAuto: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := MatchMembers(tc.text, testMembers)
|
||||
|
||||
// Check required members are present
|
||||
for _, want := range tc.wantContains {
|
||||
found := false
|
||||
for _, m := range got {
|
||||
if m.Name == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("MatchMembers(%q): want %q in result, got %v", tc.text, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Check excluded members are absent
|
||||
for _, exclude := range tc.wantExcludes {
|
||||
for _, m := range got {
|
||||
if m.Name == exclude {
|
||||
t.Errorf("MatchMembers(%q): %q should not be in result, got %v", tc.text, exclude, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check all-auto constraint
|
||||
if tc.wantAllAuto {
|
||||
for _, m := range got {
|
||||
if m.Confidence != ConfidenceAuto {
|
||||
t.Errorf("MatchMembers(%q): expected all auto, got %v", tc.text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
59
go/internal/domain/matching/name_variants.go
Normal file
59
go/internal/domain/matching/name_variants.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package matching
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
nicknameRe = regexp.MustCompile(`\(([^)]+)\)`)
|
||||
nicknameStripRe = regexp.MustCompile(`\s*\([^)]*\)\s*`)
|
||||
)
|
||||
|
||||
// BuildNameVariants returns searchable lowercase ASCII variants of a member name.
|
||||
//
|
||||
// Example: "František Vrbík (Štrúdl)" → ["frantisek vrbik", "strudl", "vrbik", "frantisek"]
|
||||
//
|
||||
// variants[0] is always the full normalized base name (no nickname). MatchMembers relies on
|
||||
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
|
||||
// dropped.
|
||||
//
|
||||
// Ports scripts/match_payments.py _build_name_variants.
|
||||
func BuildNameVariants(name string) []string {
|
||||
var nickname string
|
||||
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
||||
nickname = m[1]
|
||||
}
|
||||
|
||||
base := strings.TrimSpace(nicknameStripRe.ReplaceAllString(name, " "))
|
||||
normalizedBase := czech.Normalize(base)
|
||||
normalizedNick := czech.Normalize(nickname)
|
||||
|
||||
variants := []string{normalizedBase}
|
||||
if normalizedNick != "" {
|
||||
variants = append(variants, normalizedNick)
|
||||
}
|
||||
|
||||
parts := strings.Fields(normalizedBase)
|
||||
if len(parts) >= 2 {
|
||||
variants = append(variants, parts[len(parts)-1]) // last name
|
||||
variants = append(variants, parts[0]) // first name
|
||||
}
|
||||
|
||||
filtered := variants[:0]
|
||||
for _, v := range variants {
|
||||
if len(v) >= 3 {
|
||||
filtered = append(filtered, v)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// wordIn returns true if needle appears as a whole word in haystack.
|
||||
// Both needle and haystack must already be ASCII-folded (via czech.Normalize).
|
||||
func wordIn(needle, haystack string) bool {
|
||||
pattern := `\b` + regexp.QuoteMeta(needle) + `\b`
|
||||
matched, _ := regexp.MatchString(pattern, haystack)
|
||||
return matched
|
||||
}
|
||||
62
go/internal/domain/matching/name_variants_test.go
Normal file
62
go/internal/domain/matching/name_variants_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package matching
|
||||
|
||||
// Expected values verified against scripts/match_payments.py on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c '
|
||||
// from match_payments import _build_name_variants
|
||||
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
|
||||
// print(repr(n), "->", _build_name_variants(n))
|
||||
// '
|
||||
//
|
||||
// Output:
|
||||
//
|
||||
// 'František Vrbík (Štrúdl)' -> ['frantisek vrbik', 'strudl', 'vrbik', 'frantisek']
|
||||
// 'Tov (St)' -> ['tov']
|
||||
// 'Jana' -> ['jana']
|
||||
// ' Petr Novák ( Jenda ) ' -> ['petr novak', ' jenda ', 'novak', 'petr']
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildNameVariants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "full name with nickname",
|
||||
input: "František Vrbík (Štrúdl)",
|
||||
want: []string{"frantisek vrbik", "strudl", "vrbik", "frantisek"},
|
||||
},
|
||||
{
|
||||
name: "nickname too short filtered out",
|
||||
input: "Tov (St)",
|
||||
want: []string{"tov"},
|
||||
},
|
||||
{
|
||||
name: "single-part name no nickname",
|
||||
input: "Jana",
|
||||
want: []string{"jana"},
|
||||
},
|
||||
{
|
||||
name: "extra whitespace inside parens preserved by normalize",
|
||||
input: " Petr Novák ( Jenda ) ",
|
||||
want: []string{"petr novak", " jenda ", "novak", "petr"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := BuildNameVariants(tc.input)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("BuildNameVariants(%q)\n got %q\n want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
65
go/internal/domain/synch/synch.go
Normal file
65
go/internal/domain/synch/synch.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Package synch ports the bank-sync deduplication helper from
|
||||
// scripts/sync_fio_to_sheets.py.
|
||||
package synch
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Transaction is the projection of a Fio transaction that participates
|
||||
// in the Sync ID hash. Other fields (ks, ss, sender_account, …) are
|
||||
// intentionally excluded — they are not part of the Python hash.
|
||||
//
|
||||
// Currency: leave "" to inherit the Python default of "CZK" (matches
|
||||
// the HTML scraper path which omits the key entirely).
|
||||
type Transaction struct {
|
||||
Date string
|
||||
Amount float64
|
||||
Currency string
|
||||
Sender string
|
||||
VS string
|
||||
Message string
|
||||
BankID string
|
||||
}
|
||||
|
||||
// GenerateSyncID returns the lowercase SHA-256 hex digest of
|
||||
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
|
||||
// as the dedup key in column K of the payments sheet.
|
||||
//
|
||||
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id.
|
||||
func GenerateSyncID(tx Transaction) string {
|
||||
currency := tx.Currency
|
||||
if currency == "" {
|
||||
currency = "CZK"
|
||||
}
|
||||
raw := strings.ToLower(strings.Join([]string{
|
||||
tx.Date,
|
||||
formatAmount(tx.Amount),
|
||||
currency,
|
||||
tx.Sender,
|
||||
tx.VS,
|
||||
tx.Message,
|
||||
tx.BankID,
|
||||
}, "|"))
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// formatAmount mimics Python's str(float) for Fio transaction amounts.
|
||||
// Python uses decimal notation for abs(f) in [1e-4, 1e16) and scientific
|
||||
// notation outside that range, always adding ".0" to whole-valued decimals.
|
||||
func formatAmount(f float64) string {
|
||||
abs := math.Abs(f)
|
||||
if abs != 0 && (abs < 1e-4 || abs >= 1e16) {
|
||||
return strconv.FormatFloat(f, 'e', -1, 64)
|
||||
}
|
||||
s := strconv.FormatFloat(f, 'f', -1, 64)
|
||||
if !strings.ContainsRune(s, '.') {
|
||||
s += ".0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
119
go/internal/domain/synch/synch_test.go
Normal file
119
go/internal/domain/synch/synch_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package synch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// All expected digests verified against the live Python implementation on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python -c '
|
||||
// from sync_fio_to_sheets import generate_sync_id
|
||||
// cases = [
|
||||
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
|
||||
// {"date":"2026-01-15","amount":500.0,"sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
|
||||
// {"date":"2026-02-10","amount":1234.56,"currency":"CZK","sender":"ABC SRO","vs":"","message":"FAKTURA 42","bank_id":"xyz"},
|
||||
// {"date":"2026-03-01","amount":-500.0,"currency":"CZK","sender":"refund","vs":"","message":"","bank_id":""},
|
||||
// {"date":"2026-04-01","amount":0.0,"currency":"CZK","sender":"","vs":"","message":"","bank_id":""},
|
||||
// {"date":"","amount":0.0,"currency":"CZK","sender":"","vs":"","message":"","bank_id":""},
|
||||
// ]
|
||||
// for c in cases: print(generate_sync_id(c))
|
||||
// '
|
||||
func TestGenerateSyncID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tx Transaction
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "all fields set",
|
||||
tx: Transaction{
|
||||
Date: "2026-01-15", Amount: 500.0, Currency: "CZK",
|
||||
Sender: "Jan Novak", VS: "123", Message: "clenske 1/2026", BankID: "abc123",
|
||||
},
|
||||
want: "4ac26598b6f23965380690172156a438a7e97a97dcedf222e5afe1afbe2c1bc4",
|
||||
},
|
||||
{
|
||||
name: "currency empty defaults to CZK",
|
||||
tx: Transaction{
|
||||
Date: "2026-01-15", Amount: 500.0, Currency: "",
|
||||
Sender: "Jan Novak", VS: "123", Message: "clenske 1/2026", BankID: "abc123",
|
||||
},
|
||||
want: "4ac26598b6f23965380690172156a438a7e97a97dcedf222e5afe1afbe2c1bc4",
|
||||
},
|
||||
{
|
||||
name: "mixed-case fields lowercased before hashing",
|
||||
tx: Transaction{
|
||||
Date: "2026-02-10", Amount: 1234.56, Currency: "CZK",
|
||||
Sender: "ABC SRO", VS: "", Message: "FAKTURA 42", BankID: "xyz",
|
||||
},
|
||||
want: "d40fa224d4fa572ffcd58e308e5c6508c4d5ca087b24ef6ff9284528fc128250",
|
||||
},
|
||||
{
|
||||
name: "negative amount",
|
||||
tx: Transaction{
|
||||
Date: "2026-03-01", Amount: -500.0, Currency: "CZK",
|
||||
Sender: "refund", VS: "", Message: "", BankID: "",
|
||||
},
|
||||
want: "0c630a407160367c396a2beec08efb94c319b4d84a8b90cc2be89e6ea10c391f",
|
||||
},
|
||||
{
|
||||
name: "zero amount",
|
||||
tx: Transaction{
|
||||
Date: "2026-04-01", Amount: 0.0, Currency: "CZK",
|
||||
Sender: "", VS: "", Message: "", BankID: "",
|
||||
},
|
||||
want: "6a23ce53717cd539064d550d2c2ec5de2e9bf81016d16852820ca9b8e259331f",
|
||||
},
|
||||
{
|
||||
// Python equivalent: {"date":"","amount":0.0,"currency":"CZK","sender":"","vs":"","message":"","bank_id":""}
|
||||
// Note: Python generate_sync_id({}) hashes "" for missing amount, not "0.0".
|
||||
name: "zero-value Transaction",
|
||||
tx: Transaction{},
|
||||
want: "d33d7e391f5a43f0192bb5a34c0ec15715139125678ecef8e1324af7d943b21d",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := GenerateSyncID(tc.tx)
|
||||
if got != tc.want {
|
||||
t.Errorf("GenerateSyncID(%+v) = %q, want %q", tc.tx, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// All expected strings verified against the live Python implementation on 2026-05-06:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python -c '
|
||||
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
|
||||
// print(repr(v), "->", repr(str(v)))
|
||||
// '
|
||||
func TestFormatAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{0.0, "0.0"},
|
||||
{500.0, "500.0"},
|
||||
{-500.0, "-500.0"},
|
||||
{0.1, "0.1"},
|
||||
{1234.56, "1234.56"},
|
||||
{99999.99, "99999.99"},
|
||||
{1500000.0, "1500000.0"},
|
||||
{1e16, "1e+16"},
|
||||
{1e-5, "1e-05"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := formatAmount(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("formatAmount(%v) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
128
go/tests/fixtures/README.md
vendored
Normal file
128
go/tests/fixtures/README.md
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
# Parity Fixtures
|
||||
|
||||
Captured outputs from the live Python implementation used as ground truth for
|
||||
the Go parity test suite. All 98 files are committed and PII-free.
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
fixtures/
|
||||
pure/
|
||||
normalize/ # scripts.czech_utils.normalize
|
||||
parse_month_references/ # scripts.czech_utils.parse_month_references
|
||||
calculate_fee/ # scripts.attendance.calculate_fee
|
||||
calculate_junior_fee/ # scripts.attendance.calculate_junior_fee
|
||||
parse_czk_amount/ # scripts.infer_payments.parse_czk_amount
|
||||
generate_sync_id/ # scripts.sync_fio_to_sheets.generate_sync_id
|
||||
build_name_variants/ # scripts.match_payments._build_name_variants
|
||||
match_members/ # scripts.match_payments.match_members
|
||||
infer_transaction_details/ # scripts.match_payments.infer_transaction_details
|
||||
format_date/ # scripts.match_payments.format_date
|
||||
reconcile/ # scripts.match_payments.reconcile (10 branch-coverage cases)
|
||||
```
|
||||
|
||||
## Fixture format
|
||||
|
||||
One JSON object per file:
|
||||
|
||||
```json
|
||||
{
|
||||
"case": "range_wrap_nov_to_jan",
|
||||
"func": "scripts.czech_utils.parse_month_references",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": { "text": "...", "default_year": 2026 },
|
||||
"output": { "months": ["2025-11", "2025-12", "2026-01"] }
|
||||
}
|
||||
```
|
||||
|
||||
`captured_at` is date-only so same-day re-runs produce byte-identical files.
|
||||
|
||||
### Amount type envelope
|
||||
|
||||
Four fields carry a type envelope to distinguish Python `int` / `float` / `None`:
|
||||
|
||||
```json
|
||||
{"type": "int", "value": 750}
|
||||
{"type": "float", "value": 750.0}
|
||||
{"type": "string", "value": "..."}
|
||||
{"type": "none"}
|
||||
```
|
||||
|
||||
Fields that use envelopes: `generate_sync_id.tx.amount`, `parse_czk_amount.val`,
|
||||
`format_date.val`, `infer_transaction_details.tx.date`.
|
||||
|
||||
### Reconcile member format
|
||||
|
||||
Reconcile input members use a named dict to allow consistent PII scrubbing:
|
||||
|
||||
```json
|
||||
{"name": "Member_d035d9f9", "tier": "A", "fees": {"2026-01": [750, 3]}}
|
||||
```
|
||||
|
||||
## Running the parity tests
|
||||
|
||||
```bash
|
||||
make go-parity # run all parity tests
|
||||
make go-test-all # unit tests + parity tests
|
||||
```
|
||||
|
||||
Or directly:
|
||||
|
||||
```bash
|
||||
cd go && go test -tags=parity ./tests/parity/...
|
||||
cd go && go test -tags=parity -v -run TestReconcileParity ./tests/parity/reconcile/
|
||||
```
|
||||
|
||||
## Refresh workflow
|
||||
|
||||
Regenerate the entire corpus from the live Python implementation:
|
||||
|
||||
```bash
|
||||
make capture-fixtures
|
||||
git diff go/tests/fixtures/ # review changes before committing
|
||||
```
|
||||
|
||||
To refresh a single function:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=scripts:. python3 scripts/capture_fixtures.py --func normalize --all \
|
||||
| while IFS= read -r line; do
|
||||
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
|
||||
echo "$line" | python3 scripts/scrub_fixtures.py \
|
||||
> go/tests/fixtures/pure/normalize/${id}.json
|
||||
done
|
||||
```
|
||||
|
||||
## When to refresh
|
||||
|
||||
- A ported function is intentionally changed to match updated Python behaviour.
|
||||
- A new Czech declension or fee tier is added to the Python implementation.
|
||||
- A new reconcile code path needs fixture coverage.
|
||||
|
||||
**Do not refresh to silence a failing parity test** without first confirming that
|
||||
the Python behaviour is the correct reference. A parity failure means either the
|
||||
Go port diverges or the Python implementation changed — diagnose before regenerating.
|
||||
|
||||
## PII scrubbing audit
|
||||
|
||||
No real member names should appear in committed fixtures. Before committing any
|
||||
regenerated fixtures, verify with:
|
||||
|
||||
```bash
|
||||
# Replace with names from the real roster to check:
|
||||
git ls-files go/tests/fixtures | xargs grep -l "Real Name Here" | head
|
||||
```
|
||||
|
||||
The scrubber applies deterministic SHA-256 pseudonyms (`Member_<8hex>`) to all
|
||||
PII fields. `match_members` and `infer_transaction_details` fixtures use a
|
||||
synthetic roster of fictional names and are exempt from field-key scrubbing;
|
||||
verify that no real roster names appear in their `member_names` arrays.
|
||||
|
||||
## Adding a new fixture
|
||||
|
||||
1. Add a seed to `scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
|
||||
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
|
||||
is new.
|
||||
3. Run the single-file capture recipe above and review the diff.
|
||||
4. The parity test picks up new fixtures automatically — no test code changes needed
|
||||
(unless the function itself is new).
|
||||
15
go/tests/fixtures/pure/build_name_variants/common_diacritics.json
vendored
Normal file
15
go/tests/fixtures/pure/build_name_variants/common_diacritics.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"case": "common_diacritics",
|
||||
"func": "scripts.match_payments._build_name_variants",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"full_name": "Alžběta Testovická"
|
||||
},
|
||||
"output": {
|
||||
"variants": [
|
||||
"alzbeta testovicka",
|
||||
"testovicka",
|
||||
"alzbeta"
|
||||
]
|
||||
}
|
||||
}
|
||||
15
go/tests/fixtures/pure/build_name_variants/full_name_no_nick.json
vendored
Normal file
15
go/tests/fixtures/pure/build_name_variants/full_name_no_nick.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"case": "full_name_no_nick",
|
||||
"func": "scripts.match_payments._build_name_variants",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"full_name": "Jan Novák"
|
||||
},
|
||||
"output": {
|
||||
"variants": [
|
||||
"jan novak",
|
||||
"novak",
|
||||
"jan"
|
||||
]
|
||||
}
|
||||
}
|
||||
11
go/tests/fixtures/pure/build_name_variants/short_name_filtered.json
vendored
Normal file
11
go/tests/fixtures/pure/build_name_variants/short_name_filtered.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"case": "short_name_filtered",
|
||||
"func": "scripts.match_payments._build_name_variants",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"full_name": "Jo"
|
||||
},
|
||||
"output": {
|
||||
"variants": []
|
||||
}
|
||||
}
|
||||
13
go/tests/fixtures/pure/build_name_variants/single_word.json
vendored
Normal file
13
go/tests/fixtures/pure/build_name_variants/single_word.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"case": "single_word",
|
||||
"func": "scripts.match_payments._build_name_variants",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"full_name": "Jáchym"
|
||||
},
|
||||
"output": {
|
||||
"variants": [
|
||||
"jachym"
|
||||
]
|
||||
}
|
||||
}
|
||||
16
go/tests/fixtures/pure/build_name_variants/three_word_name.json
vendored
Normal file
16
go/tests/fixtures/pure/build_name_variants/three_word_name.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"case": "three_word_name",
|
||||
"func": "scripts.match_payments._build_name_variants",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"full_name": "Jan Tomášek (Honza)"
|
||||
},
|
||||
"output": {
|
||||
"variants": [
|
||||
"jan tomasek",
|
||||
"honza",
|
||||
"tomasek",
|
||||
"jan"
|
||||
]
|
||||
}
|
||||
}
|
||||
16
go/tests/fixtures/pure/build_name_variants/with_nickname.json
vendored
Normal file
16
go/tests/fixtures/pure/build_name_variants/with_nickname.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"case": "with_nickname",
|
||||
"func": "scripts.match_payments._build_name_variants",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"full_name": "František Vrbík (Štrúdl)"
|
||||
},
|
||||
"output": {
|
||||
"variants": [
|
||||
"frantisek vrbik",
|
||||
"strudl",
|
||||
"vrbik",
|
||||
"frantisek"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
go/tests/fixtures/pure/calculate_fee/one_session.json
vendored
Normal file
12
go/tests/fixtures/pure/calculate_fee/one_session.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "one_session",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 1,
|
||||
"month_key": "2026-01"
|
||||
},
|
||||
"output": {
|
||||
"fee": 200
|
||||
}
|
||||
}
|
||||
12
go/tests/fixtures/pure/calculate_fee/three_sessions_known_rate.json
vendored
Normal file
12
go/tests/fixtures/pure/calculate_fee/three_sessions_known_rate.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "three_sessions_known_rate",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 3,
|
||||
"month_key": "2026-02"
|
||||
},
|
||||
"output": {
|
||||
"fee": 750
|
||||
}
|
||||
}
|
||||
12
go/tests/fixtures/pure/calculate_fee/two_sessions_default_fallback.json
vendored
Normal file
12
go/tests/fixtures/pure/calculate_fee/two_sessions_default_fallback.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "two_sessions_default_fallback",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2099-01"
|
||||
},
|
||||
"output": {
|
||||
"fee": 700
|
||||
}
|
||||
}
|
||||
12
go/tests/fixtures/pure/calculate_fee/two_sessions_known_rate.json
vendored
Normal file
12
go/tests/fixtures/pure/calculate_fee/two_sessions_known_rate.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "two_sessions_known_rate",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2026-01"
|
||||
},
|
||||
"output": {
|
||||
"fee": 750
|
||||
}
|
||||
}
|
||||
12
go/tests/fixtures/pure/calculate_fee/two_sessions_reduced_march.json
vendored
Normal file
12
go/tests/fixtures/pure/calculate_fee/two_sessions_reduced_march.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "two_sessions_reduced_march",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2026-03"
|
||||
},
|
||||
"output": {
|
||||
"fee": 350
|
||||
}
|
||||
}
|
||||
12
go/tests/fixtures/pure/calculate_fee/zero_sessions.json
vendored
Normal file
12
go/tests/fixtures/pure/calculate_fee/zero_sessions.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "zero_sessions",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 0,
|
||||
"month_key": "2026-01"
|
||||
},
|
||||
"output": {
|
||||
"fee": 0
|
||||
}
|
||||
}
|
||||
13
go/tests/fixtures/pure/calculate_junior_fee/one_session_unknown.json
vendored
Normal file
13
go/tests/fixtures/pure/calculate_junior_fee/one_session_unknown.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"case": "one_session_unknown",
|
||||
"func": "scripts.attendance.calculate_junior_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 1,
|
||||
"month_key": "2026-01"
|
||||
},
|
||||
"output": {
|
||||
"value": 0,
|
||||
"unknown": true
|
||||
}
|
||||
}
|
||||
13
go/tests/fixtures/pure/calculate_junior_fee/two_sessions_default.json
vendored
Normal file
13
go/tests/fixtures/pure/calculate_junior_fee/two_sessions_default.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"case": "two_sessions_default",
|
||||
"func": "scripts.attendance.calculate_junior_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2026-01"
|
||||
},
|
||||
"output": {
|
||||
"value": 500,
|
||||
"unknown": false
|
||||
}
|
||||
}
|
||||
13
go/tests/fixtures/pure/calculate_junior_fee/two_sessions_default_fallback.json
vendored
Normal file
13
go/tests/fixtures/pure/calculate_junior_fee/two_sessions_default_fallback.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"case": "two_sessions_default_fallback",
|
||||
"func": "scripts.attendance.calculate_junior_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2099-06"
|
||||
},
|
||||
"output": {
|
||||
"value": 500,
|
||||
"unknown": false
|
||||
}
|
||||
}
|
||||
13
go/tests/fixtures/pure/calculate_junior_fee/two_sessions_reduced_march.json
vendored
Normal file
13
go/tests/fixtures/pure/calculate_junior_fee/two_sessions_reduced_march.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"case": "two_sessions_reduced_march",
|
||||
"func": "scripts.attendance.calculate_junior_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2026-03"
|
||||
},
|
||||
"output": {
|
||||
"value": 250,
|
||||
"unknown": false
|
||||
}
|
||||
}
|
||||
13
go/tests/fixtures/pure/calculate_junior_fee/two_sessions_reduced_sep.json
vendored
Normal file
13
go/tests/fixtures/pure/calculate_junior_fee/two_sessions_reduced_sep.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"case": "two_sessions_reduced_sep",
|
||||
"func": "scripts.attendance.calculate_junior_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2025-09"
|
||||
},
|
||||
"output": {
|
||||
"value": 250,
|
||||
"unknown": false
|
||||
}
|
||||
}
|
||||
13
go/tests/fixtures/pure/calculate_junior_fee/zero_sessions.json
vendored
Normal file
13
go/tests/fixtures/pure/calculate_junior_fee/zero_sessions.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"case": "zero_sessions",
|
||||
"func": "scripts.attendance.calculate_junior_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"attendance_count": 0,
|
||||
"month_key": "2026-01"
|
||||
},
|
||||
"output": {
|
||||
"value": 0,
|
||||
"unknown": false
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/format_date/empty_string.json
vendored
Normal file
14
go/tests/fixtures/pure/format_date/empty_string.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "empty_string",
|
||||
"func": "scripts.match_payments.format_date",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": ""
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"date": ""
|
||||
}
|
||||
}
|
||||
13
go/tests/fixtures/pure/format_date/none_value.json
vendored
Normal file
13
go/tests/fixtures/pure/format_date/none_value.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"case": "none_value",
|
||||
"func": "scripts.match_payments.format_date",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "none"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"date": ""
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/format_date/serial_float.json
vendored
Normal file
14
go/tests/fixtures/pure/format_date/serial_float.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "serial_float",
|
||||
"func": "scripts.match_payments.format_date",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "float",
|
||||
"value": 46027.5
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"date": "2026-01-05"
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/format_date/serial_float_exact.json
vendored
Normal file
14
go/tests/fixtures/pure/format_date/serial_float_exact.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "serial_float_exact",
|
||||
"func": "scripts.match_payments.format_date",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "float",
|
||||
"value": 45957.0
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"date": "2025-10-27"
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/format_date/serial_int.json
vendored
Normal file
14
go/tests/fixtures/pure/format_date/serial_int.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "serial_int",
|
||||
"func": "scripts.match_payments.format_date",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "int",
|
||||
"value": 46027
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"date": "2026-01-05"
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/format_date/string_iso.json
vendored
Normal file
14
go/tests/fixtures/pure/format_date/string_iso.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "string_iso",
|
||||
"func": "scripts.match_payments.format_date",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": "2026-01-15"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"date": "2026-01-15"
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/format_date/string_non_iso.json
vendored
Normal file
14
go/tests/fixtures/pure/format_date/string_non_iso.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "string_non_iso",
|
||||
"func": "scripts.match_payments.format_date",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": "garbage"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"date": "garbage"
|
||||
}
|
||||
}
|
||||
22
go/tests/fixtures/pure/generate_sync_id/empty_fields.json
vendored
Normal file
22
go/tests/fixtures/pure/generate_sync_id/empty_fields.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"case": "empty_fields",
|
||||
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"date": "2026-03-01",
|
||||
"amount": {
|
||||
"type": "float",
|
||||
"value": 0.0
|
||||
},
|
||||
"currency": "CZK",
|
||||
"sender": "",
|
||||
"vs": "",
|
||||
"message": "",
|
||||
"bank_id": ""
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"sync_id": "80d5f2762dbe807adde8dab64c3f3f00936ceafc75d4ceba232b08c09bb71c60"
|
||||
}
|
||||
}
|
||||
22
go/tests/fixtures/pure/generate_sync_id/integer_amount.json
vendored
Normal file
22
go/tests/fixtures/pure/generate_sync_id/integer_amount.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"case": "integer_amount",
|
||||
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"date": "2026-01-15",
|
||||
"amount": {
|
||||
"type": "int",
|
||||
"value": 750
|
||||
},
|
||||
"currency": "CZK",
|
||||
"sender": "Member_9b16314c",
|
||||
"vs": "864722",
|
||||
"message": "pausal leden",
|
||||
"bank_id": "983770300"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"sync_id": "155e983a0a3a11210e19728c427395f6681ee5d2a0ef3b60438e6efeaf3775df"
|
||||
}
|
||||
}
|
||||
22
go/tests/fixtures/pure/generate_sync_id/large_amount.json
vendored
Normal file
22
go/tests/fixtures/pure/generate_sync_id/large_amount.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"case": "large_amount",
|
||||
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"date": "2025-10-05",
|
||||
"amount": {
|
||||
"type": "float",
|
||||
"value": 2100.0
|
||||
},
|
||||
"currency": "CZK",
|
||||
"sender": "Member_bd5eb92a",
|
||||
"vs": "110515",
|
||||
"message": "FUJ treninky",
|
||||
"bank_id": "609470745"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"sync_id": "639d98f8ab8e6954b7e4d31508936cc4366ee0281eebc860338585cdeda43ae3"
|
||||
}
|
||||
}
|
||||
21
go/tests/fixtures/pure/generate_sync_id/missing_currency.json
vendored
Normal file
21
go/tests/fixtures/pure/generate_sync_id/missing_currency.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"case": "missing_currency",
|
||||
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"date": "2026-02-01",
|
||||
"amount": {
|
||||
"type": "float",
|
||||
"value": 500.0
|
||||
},
|
||||
"sender": "Member_32a79b03",
|
||||
"vs": "720261",
|
||||
"message": "trenink",
|
||||
"bank_id": "072657565"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"sync_id": "8bd2cc2c2e6b376ad2d2501f72ee5d987fdca37662c4be0b9bb5345dcb28553d"
|
||||
}
|
||||
}
|
||||
22
go/tests/fixtures/pure/generate_sync_id/typical_float_amount.json
vendored
Normal file
22
go/tests/fixtures/pure/generate_sync_id/typical_float_amount.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"case": "typical_float_amount",
|
||||
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"date": "2026-01-15",
|
||||
"amount": {
|
||||
"type": "float",
|
||||
"value": 750.0
|
||||
},
|
||||
"currency": "CZK",
|
||||
"sender": "Member_9b16314c",
|
||||
"vs": "864722",
|
||||
"message": "pausal leden",
|
||||
"bank_id": "983770300"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"sync_id": "155e983a0a3a11210e19728c427395f6681ee5d2a0ef3b60438e6efeaf3775df"
|
||||
}
|
||||
}
|
||||
36
go/tests/fixtures/pure/infer_transaction_details/member_in_message.json
vendored
Normal file
36
go/tests/fixtures/pure/infer_transaction_details/member_in_message.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"case": "member_in_message",
|
||||
"func": "scripts.match_payments.infer_transaction_details",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"sender": "Test Payer",
|
||||
"message": "alzbeta testovicka leden 2026",
|
||||
"user_id": "",
|
||||
"date": {
|
||||
"type": "string",
|
||||
"value": "2026-01-15"
|
||||
}
|
||||
},
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
],
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"matches": [
|
||||
{
|
||||
"name": "Alžběta Testovická",
|
||||
"confidence": "auto"
|
||||
}
|
||||
],
|
||||
"months": [
|
||||
"2026-01"
|
||||
],
|
||||
"search_text": "Test Payer alzbeta testovicka leden 2026 "
|
||||
}
|
||||
}
|
||||
36
go/tests/fixtures/pure/infer_transaction_details/member_in_sender.json
vendored
Normal file
36
go/tests/fixtures/pure/infer_transaction_details/member_in_sender.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"case": "member_in_sender",
|
||||
"func": "scripts.match_payments.infer_transaction_details",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"sender": "Tomáš Fiktivný",
|
||||
"message": "FUJ trenink",
|
||||
"user_id": "",
|
||||
"date": {
|
||||
"type": "string",
|
||||
"value": "2026-02-01"
|
||||
}
|
||||
},
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
],
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"matches": [
|
||||
{
|
||||
"name": "Tomáš Fiktivný (Tov)",
|
||||
"confidence": "auto"
|
||||
}
|
||||
],
|
||||
"months": [
|
||||
"2026-02"
|
||||
],
|
||||
"search_text": "Tomáš Fiktivný FUJ trenink "
|
||||
}
|
||||
}
|
||||
36
go/tests/fixtures/pure/infer_transaction_details/month_fallback_from_date.json
vendored
Normal file
36
go/tests/fixtures/pure/infer_transaction_details/month_fallback_from_date.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"case": "month_fallback_from_date",
|
||||
"func": "scripts.match_payments.infer_transaction_details",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"sender": "Alžběta Testovická",
|
||||
"message": "platba",
|
||||
"user_id": "",
|
||||
"date": {
|
||||
"type": "string",
|
||||
"value": "2026-03-15"
|
||||
}
|
||||
},
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
],
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"matches": [
|
||||
{
|
||||
"name": "Alžběta Testovická",
|
||||
"confidence": "auto"
|
||||
}
|
||||
],
|
||||
"months": [
|
||||
"2026-03"
|
||||
],
|
||||
"search_text": "Alžběta Testovická platba "
|
||||
}
|
||||
}
|
||||
28
go/tests/fixtures/pure/infer_transaction_details/no_member_no_month.json
vendored
Normal file
28
go/tests/fixtures/pure/infer_transaction_details/no_member_no_month.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"case": "no_member_no_month",
|
||||
"func": "scripts.match_payments.infer_transaction_details",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"sender": "Unknown Person",
|
||||
"message": "random text",
|
||||
"user_id": "",
|
||||
"date": {
|
||||
"type": "none"
|
||||
}
|
||||
},
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
],
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"matches": [],
|
||||
"months": [],
|
||||
"search_text": "Unknown Person random text "
|
||||
}
|
||||
}
|
||||
36
go/tests/fixtures/pure/infer_transaction_details/serial_date.json
vendored
Normal file
36
go/tests/fixtures/pure/infer_transaction_details/serial_date.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"case": "serial_date",
|
||||
"func": "scripts.match_payments.infer_transaction_details",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"tx": {
|
||||
"sender": "Jana Nováková",
|
||||
"message": "leden",
|
||||
"user_id": "",
|
||||
"date": {
|
||||
"type": "float",
|
||||
"value": 46027.0
|
||||
}
|
||||
},
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
],
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"matches": [
|
||||
{
|
||||
"name": "Jana Nováková",
|
||||
"confidence": "auto"
|
||||
}
|
||||
],
|
||||
"months": [
|
||||
"2026-01"
|
||||
],
|
||||
"search_text": "Jana Nováková leden "
|
||||
}
|
||||
}
|
||||
18
go/tests/fixtures/pure/match_members/common_surname_no_match.json
vendored
Normal file
18
go/tests/fixtures/pure/match_members/common_surname_no_match.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"case": "common_surname_no_match",
|
||||
"func": "scripts.match_payments.match_members",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "novak leden",
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"matches": []
|
||||
}
|
||||
}
|
||||
23
go/tests/fixtures/pure/match_members/exact_full_name.json
vendored
Normal file
23
go/tests/fixtures/pure/match_members/exact_full_name.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"case": "exact_full_name",
|
||||
"func": "scripts.match_payments.match_members",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "platba od alzbeta testovicka leden",
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"matches": [
|
||||
{
|
||||
"name": "Alžběta Testovická",
|
||||
"confidence": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
go/tests/fixtures/pure/match_members/first_and_last.json
vendored
Normal file
23
go/tests/fixtures/pure/match_members/first_and_last.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"case": "first_and_last",
|
||||
"func": "scripts.match_payments.match_members",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "jan nový payment tomas fiktivny",
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"matches": [
|
||||
{
|
||||
"name": "Tomáš Fiktivný (Tov)",
|
||||
"confidence": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
go/tests/fixtures/pure/match_members/nickname_match.json
vendored
Normal file
23
go/tests/fixtures/pure/match_members/nickname_match.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"case": "nickname_match",
|
||||
"func": "scripts.match_payments.match_members",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "payment from strudl",
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"matches": [
|
||||
{
|
||||
"name": "Pavel Smutný (Štrúdl)",
|
||||
"confidence": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
18
go/tests/fixtures/pure/match_members/no_match.json
vendored
Normal file
18
go/tests/fixtures/pure/match_members/no_match.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"case": "no_match",
|
||||
"func": "scripts.match_payments.match_members",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "xyz platba",
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"matches": []
|
||||
}
|
||||
}
|
||||
23
go/tests/fixtures/pure/match_members/review_lastname_only.json
vendored
Normal file
23
go/tests/fixtures/pure/match_members/review_lastname_only.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"case": "review_lastname_only",
|
||||
"func": "scripts.match_payments.match_members",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "testovicka leden",
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"matches": [
|
||||
{
|
||||
"name": "Alžběta Testovická",
|
||||
"confidence": "review"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
27
go/tests/fixtures/pure/match_members/two_members_exact.json
vendored
Normal file
27
go/tests/fixtures/pure/match_members/two_members_exact.json
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"case": "two_members_exact",
|
||||
"func": "scripts.match_payments.match_members",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "pavel smutny a alzbeta testovicka",
|
||||
"member_names": [
|
||||
"Alžběta Testovická",
|
||||
"Tomáš Fiktivný (Tov)",
|
||||
"Pavel Smutný (Štrúdl)",
|
||||
"Jana Nováková",
|
||||
"Adam Novák"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"matches": [
|
||||
{
|
||||
"name": "Alžběta Testovická",
|
||||
"confidence": "auto"
|
||||
},
|
||||
{
|
||||
"name": "Pavel Smutný (Štrúdl)",
|
||||
"confidence": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
go/tests/fixtures/pure/normalize/czech_basic.json
vendored
Normal file
11
go/tests/fixtures/pure/normalize/czech_basic.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"case": "czech_basic",
|
||||
"func": "scripts.czech_utils.normalize",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "štefan čakrtový"
|
||||
},
|
||||
"output": {
|
||||
"text": "stefan cakrtovy"
|
||||
}
|
||||
}
|
||||
11
go/tests/fixtures/pure/normalize/czech_full_set.json
vendored
Normal file
11
go/tests/fixtures/pure/normalize/czech_full_set.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"case": "czech_full_set",
|
||||
"func": "scripts.czech_utils.normalize",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "áčďéěíňóřšťůúýžÁČĎÉĚÍŇÓŘŠŤŮÚÝŽ"
|
||||
},
|
||||
"output": {
|
||||
"text": "acdeeinorstuuyzacdeeinorstuuyz"
|
||||
}
|
||||
}
|
||||
11
go/tests/fixtures/pure/normalize/digits_symbols.json
vendored
Normal file
11
go/tests/fixtures/pure/normalize/digits_symbols.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"case": "digits_symbols",
|
||||
"func": "scripts.czech_utils.normalize",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "FUJ2026! +3"
|
||||
},
|
||||
"output": {
|
||||
"text": "fuj2026! +3"
|
||||
}
|
||||
}
|
||||
11
go/tests/fixtures/pure/normalize/empty_string.json
vendored
Normal file
11
go/tests/fixtures/pure/normalize/empty_string.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"case": "empty_string",
|
||||
"func": "scripts.czech_utils.normalize",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": ""
|
||||
},
|
||||
"output": {
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
11
go/tests/fixtures/pure/normalize/mixed_case.json
vendored
Normal file
11
go/tests/fixtures/pure/normalize/mixed_case.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"case": "mixed_case",
|
||||
"func": "scripts.czech_utils.normalize",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "Henrietta OTTOVÁ"
|
||||
},
|
||||
"output": {
|
||||
"text": "henrietta ottova"
|
||||
}
|
||||
}
|
||||
11
go/tests/fixtures/pure/normalize/simple_ascii.json
vendored
Normal file
11
go/tests/fixtures/pure/normalize/simple_ascii.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"case": "simple_ascii",
|
||||
"func": "scripts.czech_utils.normalize",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "hello world"
|
||||
},
|
||||
"output": {
|
||||
"text": "hello world"
|
||||
}
|
||||
}
|
||||
11
go/tests/fixtures/pure/normalize/with_parens.json
vendored
Normal file
11
go/tests/fixtures/pure/normalize/with_parens.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"case": "with_parens",
|
||||
"func": "scripts.czech_utils.normalize",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "Pavel Smutný (Štrúdl)"
|
||||
},
|
||||
"output": {
|
||||
"text": "pavel smutny (strudl)"
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/czech_comma_decimal.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/czech_comma_decimal.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "czech_comma_decimal",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": "1.500,00"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 1500.0
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/czech_comma_no_thousands.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/czech_comma_no_thousands.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "czech_comma_no_thousands",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": "750,00"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 750.0
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/dot_decimal.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/dot_decimal.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "dot_decimal",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": "1500.00"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 1500.0
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/dot_thousand_separator.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/dot_thousand_separator.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "dot_thousand_separator",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": "1.500"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 1.5
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/empty_string.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/empty_string.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "empty_string",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": ""
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 0.0
|
||||
}
|
||||
}
|
||||
13
go/tests/fixtures/pure/parse_czk_amount/none_value.json
vendored
Normal file
13
go/tests/fixtures/pure/parse_czk_amount/none_value.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"case": "none_value",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "none"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 0.0
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/plain_float.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/plain_float.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "plain_float",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "float",
|
||||
"value": 750.0
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 750.0
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/plain_int.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/plain_int.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "plain_int",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "int",
|
||||
"value": 750
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 750.0
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/space_thousands.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/space_thousands.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "space_thousands",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": "1 500"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 1500.0
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/with_czk_suffix.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/with_czk_suffix.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "with_czk_suffix",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": "1500CZK"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 1500.0
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_czk_amount/with_kc_suffix.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_czk_amount/with_kc_suffix.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "with_kc_suffix",
|
||||
"func": "scripts.infer_payments.parse_czk_amount",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"value": "750 Kč"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"amount": 750.0
|
||||
}
|
||||
}
|
||||
12
go/tests/fixtures/pure/parse_month_references/empty_string.json
vendored
Normal file
12
go/tests/fixtures/pure/parse_month_references/empty_string.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "empty_string",
|
||||
"func": "scripts.czech_utils.parse_month_references",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "",
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"months": []
|
||||
}
|
||||
}
|
||||
16
go/tests/fixtures/pure/parse_month_references/mixed_czech_numeric.json
vendored
Normal file
16
go/tests/fixtures/pure/parse_month_references/mixed_czech_numeric.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"case": "mixed_czech_numeric",
|
||||
"func": "scripts.czech_utils.parse_month_references",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "leden+únor+03/2026",
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"months": [
|
||||
"2026-01",
|
||||
"2026-02",
|
||||
"2026-03"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
go/tests/fixtures/pure/parse_month_references/no_month_found.json
vendored
Normal file
12
go/tests/fixtures/pure/parse_month_references/no_month_found.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "no_month_found",
|
||||
"func": "scripts.czech_utils.parse_month_references",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "random text without months",
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"months": []
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_month_references/numeric_dot_format.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_month_references/numeric_dot_format.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "numeric_dot_format",
|
||||
"func": "scripts.czech_utils.parse_month_references",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "12.2025",
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"months": [
|
||||
"2025-12"
|
||||
]
|
||||
}
|
||||
}
|
||||
15
go/tests/fixtures/pure/parse_month_references/numeric_plus_multi.json
vendored
Normal file
15
go/tests/fixtures/pure/parse_month_references/numeric_plus_multi.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"case": "numeric_plus_multi",
|
||||
"func": "scripts.czech_utils.parse_month_references",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "11+12/2025",
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"months": [
|
||||
"2025-11",
|
||||
"2025-12"
|
||||
]
|
||||
}
|
||||
}
|
||||
14
go/tests/fixtures/pure/parse_month_references/numeric_slash_four_digit_year.json
vendored
Normal file
14
go/tests/fixtures/pure/parse_month_references/numeric_slash_four_digit_year.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"case": "numeric_slash_four_digit_year",
|
||||
"func": "scripts.czech_utils.parse_month_references",
|
||||
"captured_at": "2026-05-06",
|
||||
"input": {
|
||||
"text": "1/2026",
|
||||
"default_year": 2026
|
||||
},
|
||||
"output": {
|
||||
"months": [
|
||||
"2026-01"
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user