Merge pull request 'feat(go): fixture capture + characterization framework (M3)' (#12) from feat/m3-fixture-capture into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-05-06 21:29:48 +00:00
119 changed files with 4931 additions and 10 deletions

View File

@@ -1,5 +1,16 @@
# 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.

View File

@@ -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)

View File

@@ -2,7 +2,7 @@
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
**Current milestone:** M2Pure-domain helpers
**Current milestone:** M3Fixture capture + characterization framework ✅
**Started:** 2026-05-04
**Last updated:** 2026-05-06
@@ -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.1M2.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.1M2.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.
---

View 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.1M2.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.1M2.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.1M3.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.1M3.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.1M3.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.1M4.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.1M3.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.1M3.6).

128
go/tests/fixtures/README.md vendored Normal file
View 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).

View 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"
]
}
}

View 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"
]
}
}

View 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": []
}
}

View 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"
]
}
}

View 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"
]
}
}

View 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"
]
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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": ""
}
}

View 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": ""
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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 "
}
}

View 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 "
}
}

View 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 "
}
}

View 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 "
}
}

View 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 "
}
}

View 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": []
}
}

View 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"
}
]
}
}

View 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"
}
]
}
}

View 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"
}
]
}
}

View 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": []
}
}

View 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"
}
]
}
}

View 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"
}
]
}
}

View 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"
}
}

View File

@@ -0,0 +1,11 @@
{
"case": "czech_full_set",
"func": "scripts.czech_utils.normalize",
"captured_at": "2026-05-06",
"input": {
"text": "áčďéěíňóřšťůúýžÁČĎÉĚÍŇÓŘŠŤŮÚÝŽ"
},
"output": {
"text": "acdeeinorstuuyzacdeeinorstuuyz"
}
}

View 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"
}
}

View File

@@ -0,0 +1,11 @@
{
"case": "empty_string",
"func": "scripts.czech_utils.normalize",
"captured_at": "2026-05-06",
"input": {
"text": ""
},
"output": {
"text": ""
}
}

View 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"
}
}

View 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"
}
}

View 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)"
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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": []
}
}

View 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"
]
}
}

View 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": []
}
}

View 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"
]
}
}

View 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"
]
}
}

View 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"
]
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "numeric_slash_leading_zero",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "03/2026",
"default_year": 2026
},
"output": {
"months": [
"2026-03"
]
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "numeric_slash_two_digit_year",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "01/26",
"default_year": 2026
},
"output": {
"months": [
"2026-01"
]
}
}

View File

@@ -0,0 +1,15 @@
{
"case": "range_no_wrap_leden_unor",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "leden-únor",
"default_year": 2026
},
"output": {
"months": [
"2026-01",
"2026-02"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"case": "range_wrap_listopad_leden",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "listopad-leden",
"default_year": 2026
},
"output": {
"months": [
"2025-11",
"2025-12",
"2026-01"
]
}
}

View File

@@ -0,0 +1,15 @@
{
"case": "range_wrap_prosinec_leden",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "prosinec-leden",
"default_year": 2026
},
"output": {
"months": [
"2025-12",
"2026-01"
]
}
}

View File

@@ -0,0 +1,19 @@
{
"case": "real_alex_numeric_long",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_3f7108b7: 10/2025+11/2025+01/2026+02/2026+03/2026+04/2026",
"default_year": 2026
},
"output": {
"months": [
"2025-10",
"2025-11",
"2026-01",
"2026-02",
"2026-03",
"2026-04"
]
}
}

View File

@@ -0,0 +1,17 @@
{
"case": "real_dominika_numeric_multi",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_22e1170d paušál 11+12/25, 01/26, 02/26",
"default_year": 2026
},
"output": {
"months": [
"2025-11",
"2025-12",
"2026-01",
"2026-02"
]
}
}

View File

@@ -0,0 +1,19 @@
{
"case": "real_emily_numeric_long",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_b09f5558: 10/2025+11/2025+01/2026+02/2026+03/2026+04/2026",
"default_year": 2026
},
"output": {
"months": [
"2025-10",
"2025-11",
"2026-01",
"2026-02",
"2026-03",
"2026-04"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"case": "real_filip_prosinec_leden_unor",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Filip Halamka - prosinec, leden, unor",
"default_year": 2026
},
"output": {
"months": [
"2025-12",
"2026-01",
"2026-02"
]
}
}

View File

@@ -0,0 +1,15 @@
{
"case": "real_franc_numeric_space",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_f42b5277:02/2026 03/2026",
"default_year": 2026
},
"output": {
"months": [
"2026-02",
"2026-03"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"case": "real_jachym_numeric_multi",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Jáchym Kubík: 01/2026+03/2026+04/2026",
"default_year": 2026
},
"output": {
"months": [
"2026-01",
"2026-03",
"2026-04"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"case": "real_jana_numeric_multi",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_ca47f547: 02/2026+03/2026+04/2026",
"default_year": 2026
},
"output": {
"months": [
"2026-02",
"2026-03",
"2026-04"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"case": "real_list_prosinec_leden_unor",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Kacerr - pausal prosinec, leden, unor",
"default_year": 2026
},
"output": {
"months": [
"2025-12",
"2026-01",
"2026-02"
]
}
}

View File

@@ -0,0 +1,15 @@
{
"case": "real_martin_prosinec_leden",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Martin Bolvansky Pausal Prosinec Leden",
"default_year": 2026
},
"output": {
"months": [
"2025-12",
"2026-01"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"case": "real_mixed_czech_numeric",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_7e9cb37a paušál leden+únor a 500 za 11,12/2025",
"default_year": 2026
},
"output": {
"months": [
"2025-12",
"2026-01",
"2026-02"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"case": "real_range_listopad_leden",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_3f0f0061 pausal listopad-leden",
"default_year": 2026
},
"output": {
"months": [
"2025-11",
"2025-12",
"2026-01"
]
}
}

View File

@@ -0,0 +1,15 @@
{
"case": "real_range_prosinec_leden",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_8fa4ba0e prosinec-leden",
"default_year": 2026
},
"output": {
"months": [
"2025-12",
"2026-01"
]
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "real_single_leden",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_89d22e73, paušál za leden 2026",
"default_year": 2026
},
"output": {
"months": [
"2026-01"
]
}
}

View File

@@ -0,0 +1,15 @@
{
"case": "real_tomik_numeric_plus",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "Member_e4654d4c: 02/2026+03/2026",
"default_year": 2026
},
"output": {
"months": [
"2026-02",
"2026-03"
]
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "single_czech_leden",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "leden",
"default_year": 2026
},
"output": {
"months": [
"2026-01"
]
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "single_czech_prosinec_high_month",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "prosinec",
"default_year": 2026
},
"output": {
"months": [
"2025-12"
]
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "single_czech_rijen_high_month",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": {
"text": "říjen",
"default_year": 2026
},
"output": {
"months": [
"2025-10"
]
}
}

View File

@@ -0,0 +1,68 @@
{
"case": "01_greedy_exact",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06",
"input": {
"members": [
{
"name": "Member_d035d9f9",
"tier": "A",
"fees": {
"2026-01": [
750,
3
]
}
}
],
"sorted_months": [
"2026-01"
],
"transactions": [
{
"date": "2026-01-20",
"amount": 750,
"manual_fix": "",
"person": "Member_d035d9f9",
"purpose": "2026-01",
"inferred_amount": 750,
"sender": "Member_d035d9f9",
"message": "",
"bank_id": ""
}
],
"exceptions": [],
"default_year": 2026
},
"output": {
"members": {
"Member_d035d9f9": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750,
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 750.0,
"transactions": [
{
"amount": 750.0,
"date": "2026-01-20",
"sender": "Member_d035d9f9",
"message": "",
"confidence": "auto"
}
]
}
},
"other_transactions": [],
"total_balance": 0
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": 0
}
}
}

View File

@@ -0,0 +1,68 @@
{
"case": "02_greedy_overpayment",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06",
"input": {
"members": [
{
"name": "Member_d035d9f9",
"tier": "A",
"fees": {
"2026-01": [
750,
3
]
}
}
],
"sorted_months": [
"2026-01"
],
"transactions": [
{
"date": "2026-01-20",
"amount": 900,
"manual_fix": "",
"person": "Member_d035d9f9",
"purpose": "2026-01",
"inferred_amount": 900,
"sender": "Member_d035d9f9",
"message": "",
"bank_id": ""
}
],
"exceptions": [],
"default_year": 2026
},
"output": {
"members": {
"Member_d035d9f9": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750,
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 750.0,
"transactions": [
{
"amount": 750.0,
"date": "2026-01-20",
"sender": "Member_d035d9f9",
"message": "",
"confidence": "auto"
}
]
}
},
"other_transactions": [],
"total_balance": 150
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": 150
}
}
}

View File

@@ -0,0 +1,110 @@
{
"case": "03_proportional_remainder",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06",
"input": {
"members": [
{
"name": "Member_d035d9f9",
"tier": "A",
"fees": {
"2026-01": [
750,
3
],
"2026-02": [
750,
2
],
"2026-03": [
350,
2
]
}
}
],
"sorted_months": [
"2026-01",
"2026-02",
"2026-03"
],
"transactions": [
{
"date": "2026-03-10",
"amount": 800,
"manual_fix": "",
"person": "Member_d035d9f9",
"purpose": "2026-01,2026-02,2026-03",
"inferred_amount": 800,
"sender": "Member_d035d9f9",
"message": "",
"bank_id": ""
}
],
"exceptions": [],
"default_year": 2026
},
"output": {
"members": {
"Member_d035d9f9": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750,
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 324.3243243243243,
"transactions": [
{
"amount": 324.3243243243243,
"date": "2026-03-10",
"sender": "Member_d035d9f9",
"message": "",
"confidence": "auto"
}
]
},
"2026-02": {
"expected": 750,
"original_expected": 750,
"attendance_count": 2,
"exception": null,
"paid": 324.3243243243243,
"transactions": [
{
"amount": 324.3243243243243,
"date": "2026-03-10",
"sender": "Member_d035d9f9",
"message": "",
"confidence": "auto"
}
]
},
"2026-03": {
"expected": 350,
"original_expected": 350,
"attendance_count": 2,
"exception": null,
"paid": 151.35135135135135,
"transactions": [
{
"amount": 151.35135135135135,
"date": "2026-03-10",
"sender": "Member_d035d9f9",
"message": "",
"confidence": "auto"
}
]
}
},
"other_transactions": [],
"total_balance": -1051
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": -1051
}
}
}

View File

@@ -0,0 +1,89 @@
{
"case": "04_even_split_prepayment",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06",
"input": {
"members": [
{
"name": "Member_f4a93e46",
"tier": "A",
"fees": {
"2026-04": [
0,
0
],
"2026-05": [
0,
0
]
}
}
],
"sorted_months": [
"2026-04",
"2026-05"
],
"transactions": [
{
"date": "2026-03-25",
"amount": 700,
"manual_fix": "",
"person": "Member_f4a93e46",
"purpose": "2026-04,2026-05",
"inferred_amount": 700,
"sender": "Member_f4a93e46",
"message": "",
"bank_id": ""
}
],
"exceptions": [],
"default_year": 2026
},
"output": {
"members": {
"Member_f4a93e46": {
"tier": "A",
"months": {
"2026-04": {
"expected": 0,
"original_expected": 0,
"attendance_count": 0,
"exception": null,
"paid": 350.0,
"transactions": [
{
"amount": 350.0,
"date": "2026-03-25",
"sender": "Member_f4a93e46",
"message": "",
"confidence": "auto"
}
]
},
"2026-05": {
"expected": 0,
"original_expected": 0,
"attendance_count": 0,
"exception": null,
"paid": 350.0,
"transactions": [
{
"amount": 350.0,
"date": "2026-03-25",
"sender": "Member_f4a93e46",
"message": "",
"confidence": "auto"
}
]
}
},
"other_transactions": [],
"total_balance": 700
}
},
"unmatched": [],
"credits": {
"Member_f4a93e46": 700
}
}
}

View File

@@ -0,0 +1,68 @@
{
"case": "05_out_of_window_credit",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06",
"input": {
"members": [
{
"name": "Member_d035d9f9",
"tier": "A",
"fees": {
"2026-01": [
750,
3
]
}
}
],
"sorted_months": [
"2026-01"
],
"transactions": [
{
"date": "2026-01-20",
"amount": 1500,
"manual_fix": "",
"person": "Member_d035d9f9",
"purpose": "2026-01,2025-08",
"inferred_amount": 1500,
"sender": "Member_d035d9f9",
"message": "",
"bank_id": ""
}
],
"exceptions": [],
"default_year": 2026
},
"output": {
"members": {
"Member_d035d9f9": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750,
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 750.0,
"transactions": [
{
"amount": 750.0,
"date": "2026-01-20",
"sender": "Member_d035d9f9",
"message": "",
"confidence": "auto"
}
]
}
},
"other_transactions": [],
"total_balance": 750
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": 750
}
}
}

View File

@@ -0,0 +1,78 @@
{
"case": "06_exception_override",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06",
"input": {
"members": [
{
"name": "Member_d035d9f9",
"tier": "A",
"fees": {
"2026-01": [
750,
3
]
}
}
],
"sorted_months": [
"2026-01"
],
"transactions": [
{
"date": "2026-01-20",
"amount": 300,
"manual_fix": "",
"person": "Member_d035d9f9",
"purpose": "2026-01",
"inferred_amount": 300,
"sender": "Member_d035d9f9",
"message": "",
"bank_id": ""
}
],
"exceptions": [
{
"name": "Member_d035d9f9",
"period": "2026-01",
"amount": 300,
"note": "<scrubbed>"
}
],
"default_year": 2026
},
"output": {
"members": {
"Member_d035d9f9": {
"tier": "A",
"months": {
"2026-01": {
"expected": 300,
"original_expected": 750,
"attendance_count": 3,
"exception": {
"amount": 300,
"note": "<scrubbed>"
},
"paid": 300.0,
"transactions": [
{
"amount": 300.0,
"date": "2026-01-20",
"sender": "Member_d035d9f9",
"message": "",
"confidence": "auto"
}
]
}
},
"other_transactions": [],
"total_balance": 0
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": 0
}
}
}

View File

@@ -0,0 +1,104 @@
{
"case": "07_other_purpose_split",
"func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06",
"input": {
"members": [
{
"name": "Member_d035d9f9",
"tier": "A",
"fees": {
"2026-01": [
750,
3
]
}
},
{
"name": "Member_f4a93e46",
"tier": "A",
"fees": {
"2026-01": [
750,
2
]
}
}
],
"sorted_months": [
"2026-01"
],
"transactions": [
{
"date": "2026-01-10",
"amount": 800,
"manual_fix": "",
"person": "Member_d035d9f9, Member_f4a93e46",
"purpose": "other:tournament",
"inferred_amount": 800,
"sender": "Member_d035d9f9",
"message": "",
"bank_id": ""
}
],
"exceptions": [],
"default_year": 2026
},
"output": {
"members": {
"Member_d035d9f9": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750,
"original_expected": 750,
"attendance_count": 3,
"exception": null,
"paid": 0.0,
"transactions": []
}
},
"other_transactions": [
{
"amount": 400.0,
"date": "2026-01-10",
"sender": "Member_d035d9f9",
"message": "",
"purpose": "other:tournament",
"confidence": "auto"
}
],
"total_balance": -750
},
"Member_f4a93e46": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750,
"original_expected": 750,
"attendance_count": 2,
"exception": null,
"paid": 0.0,
"transactions": []
}
},
"other_transactions": [
{
"amount": 400.0,
"date": "2026-01-10",
"sender": "Member_d035d9f9",
"message": "",
"purpose": "other:tournament",
"confidence": "auto"
}
],
"total_balance": -750
}
},
"unmatched": [],
"credits": {
"Member_d035d9f9": -750,
"Member_f4a93e46": -750
}
}
}

Some files were not shown because too many files have changed in this diff Show More