Compare commits
23 Commits
0.31
...
9e6aebc816
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e6aebc816 | |||
| c53bf5a1c3 | |||
| 3e597242eb | |||
| 7232697e9c | |||
| e596f0000e | |||
| c2bffed1b8 | |||
| 54a783ea00 | |||
| 84a5d177e9 | |||
| 1a63bfd313 | |||
| d24d20553a | |||
| fa853780db | |||
| 0fc3b6dd9a | |||
| 57ec817044 | |||
| 6cf83a01e3 | |||
| 98f401c149 | |||
| 0a8017fffa | |||
| 6d971b61d4 | |||
| 3460f57c62 | |||
| 6ca35e2112 | |||
| 20ade6de3e | |||
| d9a61b338c | |||
| 91ac3b37cf | |||
| 394da2e6b8 |
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## 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`.
|
||||
- Preserves the Czech-locale heuristic: comma → decimal sep; 2+ dots → thousand seps; single dot → decimal (so `"1.500"` → `1.5`).
|
||||
- Returns `(0, ErrInvalidAmount)` on parse failure; callers wanting Python's silent-zero contract use `v, _ := ParseCZK(s)`.
|
||||
- 15 table-driven tests plus a silent-zero contract test; all expected values verified against live Python on 2026-05-06.
|
||||
|
||||
## 2026-05-06 09:24 CEST — feat(go/M2.3+M2.4): port domain/fees.CalculateFee and CalculateJuniorFee
|
||||
|
||||
- New `go/internal/domain/fees` package with adult and junior fee calculators ported from `scripts/attendance.py`.
|
||||
- `CalculateFee(count, monthKey) int` — `0→0`, `1→200`, `2+→AdultFeeMonthlyRate[month]` (fallback 700 CZK).
|
||||
- `CalculateJuniorFee(count, monthKey) Expected` — `0→{0}`, `1→{Unknown:true}` (the `"?"` sentinel, now strictly typed), `2+→JuniorFeeMonthlyRate[month]` (fallback 500 CZK).
|
||||
- 20 table-driven tests, all verified against live Python; `-race` clean; `golangci-lint` clean.
|
||||
|
||||
## 2026-05-06 00:07 CEST — feat(go/M2.2): port czech.ParseMonthReferences
|
||||
|
||||
- `internal/domain/czech.ParseMonthReferences`: three-pass regex (numeric slash, dot, Czech month names) with range wrap-around and `m≥10 → previousYear` heuristic, byte-equivalent to Python.
|
||||
- 35 table-driven tests; all expected outputs verified against live Python before locking (addresses risk #4 from the rewrite plan).
|
||||
|
||||
## 2026-05-05 23:33 CEST — feat(go/M2.1): port czech.Normalize
|
||||
|
||||
- First M2 pure-domain task: `internal/domain/czech.Normalize` (NFKD + Mn-strip + lowercase), byte-equivalent to Python `czech_utils.normalize`.
|
||||
- Adds `golang.org/x/text v0.36.0` as first external Go dependency.
|
||||
- 13-case table-driven test, all spot-checked against Python before locking.
|
||||
|
||||
## 2026-05-04 23:08 CEST — fix: payment inference exact-match short-circuit
|
||||
|
||||
- `match_members()` now short-circuits on whole-word full-name hits; nickname/partial checks only run when no full name is present.
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@@ -64,13 +64,13 @@ Fio Bank API ──► sync_fio_to_sheets.py ──► Google Shee
|
||||
### Member tiers
|
||||
|
||||
Tiers are set in column B of the attendance sheet:
|
||||
- `A` — Adult, pays fees (750 CZK/month for 2+ sessions, 200 CZK for exactly 1)
|
||||
- `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
|
||||
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
||||
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
||||
|
||||
### Fee calculation
|
||||
|
||||
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 750 CZK)
|
||||
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK)
|
||||
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK)
|
||||
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
|
||||
|
||||
@@ -92,6 +92,45 @@ Tiers are set in column B of the attendance sheet:
|
||||
|
||||
`/qr?account=…&amount=…&message=…` generates a Czech QR Platba PNG (SPD format).
|
||||
|
||||
## Branching & merge requests
|
||||
|
||||
The remote is Gitea (`gitea.home.hrajfrisbee.cz/kacerr/fuj-management`).
|
||||
For **features**, do not commit to `main` directly. Use a branch + merge
|
||||
request flow:
|
||||
|
||||
1. **Create a branch off `main`** before starting work:
|
||||
- `feat/<slug>` for features (e.g. `feat/qr-code-overlay`)
|
||||
- `fix/<slug>` for bug-fix branches the user explicitly asks for
|
||||
- `<slug>` is short kebab-case
|
||||
2. **Commit on the branch** following the existing commit conventions
|
||||
(Co-Authored-By trailer, etc.).
|
||||
3. **Push the branch** to `origin` with `-u` so it tracks.
|
||||
4. **Open the MR with `tea`** rather than printing a compare URL:
|
||||
|
||||
```bash
|
||||
tea pr create \
|
||||
--title "<short title>" \
|
||||
--description "<body>" \
|
||||
--base main \
|
||||
--head <branch>
|
||||
```
|
||||
|
||||
`tea` is already authenticated against the Gitea instance; just run it.
|
||||
Print the resulting PR URL for the user. If `tea` is unavailable for
|
||||
some reason, fall back to printing the compare URL
|
||||
(`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...<branch>`)
|
||||
and let the user open the MR manually.
|
||||
5. **Do not merge or delete the branch** from the CLI — neither via `tea`,
|
||||
`gh`, nor `git push --delete`. The user does that in Gitea.
|
||||
|
||||
**Exceptions — when committing straight to `main` is fine:**
|
||||
- Small bug fixes / hotfixes the user describes as such.
|
||||
- Typo / comment / formatting tweaks.
|
||||
- Edits the user explicitly says to push to `main`.
|
||||
|
||||
When uncertain whether something is a feature or a small fix, ask before
|
||||
committing.
|
||||
|
||||
## Git Commits
|
||||
|
||||
When making git commits, always append yourself as co-author trailer to the end of the commit message to indicate AI assistance
|
||||
|
||||
62
app.py
62
app.py
@@ -22,7 +22,7 @@ from config import (
|
||||
BANK_ACCOUNT, CREDENTIALS_PATH,
|
||||
)
|
||||
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize
|
||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, canonical_member_key
|
||||
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
|
||||
from sync_fio_to_sheets import sync_to_sheets
|
||||
from infer_payments import infer_payments
|
||||
@@ -57,6 +57,25 @@ def get_month_labels(sorted_months, merged_months):
|
||||
labels[m] = dt.strftime("%b %Y")
|
||||
return labels
|
||||
|
||||
def group_payments_by_person(transactions, member_names=None):
|
||||
canonical_by_key = (
|
||||
{canonical_member_key(n): n for n in member_names} if member_names else {}
|
||||
)
|
||||
grouped = {}
|
||||
for tx in transactions:
|
||||
person = str(tx.get("person", "")).strip()
|
||||
if not person:
|
||||
continue
|
||||
for p in person.split(","):
|
||||
p = re.sub(r"\[\?\]\s*", "", p).strip()
|
||||
if not p:
|
||||
continue
|
||||
key = canonical_by_key.get(canonical_member_key(p), p)
|
||||
grouped.setdefault(key, []).append(tx)
|
||||
for rows in grouped.values():
|
||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||
return grouped
|
||||
|
||||
def warmup_cache():
|
||||
"""Pre-fetch all cached data so first request is fast."""
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -304,6 +323,7 @@ def adults_view():
|
||||
unmatched = result["unmatched"]
|
||||
import json
|
||||
|
||||
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in members])
|
||||
record_step("process_data")
|
||||
|
||||
return render_template(
|
||||
@@ -314,6 +334,7 @@ def adults_view():
|
||||
totals=formatted_totals,
|
||||
member_data=json.dumps(result["members"]),
|
||||
month_labels_json=json.dumps(month_labels),
|
||||
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||
credits=credits,
|
||||
debts=debts,
|
||||
unmatched=unmatched,
|
||||
@@ -506,6 +527,7 @@ def juniors_view():
|
||||
credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"])
|
||||
debts = sorted([{"name": n, "amount": abs(junior_settled_balance(n))} for n in junior_all_names if junior_settled_balance(n) < 0], key=lambda x: x["name"])
|
||||
unmatched = result["unmatched"]
|
||||
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in adapted_members])
|
||||
import json
|
||||
|
||||
record_step("process_data")
|
||||
@@ -518,6 +540,7 @@ def juniors_view():
|
||||
totals=formatted_totals,
|
||||
member_data=json.dumps(result["members"]),
|
||||
month_labels_json=json.dumps(month_labels),
|
||||
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||
credits=credits,
|
||||
debts=debts,
|
||||
unmatched=unmatched,
|
||||
@@ -535,29 +558,24 @@ def payments():
|
||||
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
|
||||
# Group transactions by person
|
||||
grouped = {}
|
||||
|
||||
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
member_names = []
|
||||
if adults_data:
|
||||
member_names.extend(name for name, _, _ in adults_data[0])
|
||||
if juniors_data:
|
||||
member_names.extend(name for name, _, _ in juniors_data[0])
|
||||
|
||||
grouped = group_payments_by_person(transactions, member_names)
|
||||
# payments page also groups unmatched rows under a fallback key
|
||||
for tx in transactions:
|
||||
person = str(tx.get("person", "")).strip()
|
||||
if not person:
|
||||
person = "Unmatched / Unknown"
|
||||
|
||||
# Handle multiple people (comma separated)
|
||||
people = [p.strip() for p in person.split(",") if p.strip()]
|
||||
for p in people:
|
||||
# Strip markers
|
||||
clean_p = re.sub(r"\[\?\]\s*", "", p)
|
||||
if clean_p not in grouped:
|
||||
grouped[clean_p] = []
|
||||
grouped[clean_p].append(tx)
|
||||
|
||||
# Sort people and their transactions
|
||||
if not str(tx.get("person", "")).strip():
|
||||
grouped.setdefault("Unmatched / Unknown", []).append(tx)
|
||||
for rows in grouped.values():
|
||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||
sorted_people = sorted(grouped.keys())
|
||||
for p in sorted_people:
|
||||
# Sort by date descending
|
||||
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
|
||||
|
||||
|
||||
record_step("process_data")
|
||||
return render_template(
|
||||
"payments.html",
|
||||
|
||||
@@ -4,7 +4,7 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-
|
||||
|
||||
**Current milestone:** M2 — Pure-domain helpers
|
||||
**Started:** 2026-05-04
|
||||
**Last updated:** 2026-05-04
|
||||
**Last updated:** 2026-05-06
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -44,16 +44,16 @@ Goal: every pure function from the Python backend exists in Go with a parity tes
|
||||
|
||||
Each task: port the function, write Go unit tests for fresh cases, hook into the Tier-1 parity runner.
|
||||
|
||||
- [ ] **M2.1** `domain/czech.Normalize` — port [czech_utils.py](scripts/czech_utils.py) `normalize` (NFKD + combining-mark strip + lowercase)
|
||||
- [ ] **M2.2** `domain/czech.ParseMonthReferences` — port `parse_month_references` (45 month declensions, range wrap, year inference)
|
||||
- [ ] **M2.3** `domain/fees.CalculateFee` — port [attendance.py](scripts/attendance.py) `calculate_fee` (constants table)
|
||||
- [ ] **M2.4** `domain/fees.CalculateJuniorFee` — port `calculate_junior_fee` with `Expected{Value int; Unknown bool}` for the `"?"` sentinel
|
||||
- [ ] **M2.5** `domain/money.ParseCZK` — port [infer_payments.py](scripts/infer_payments.py) `parse_czk_amount` (Czech locale: comma decimal, dot/space thousand separators)
|
||||
- [ ] **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.
|
||||
- [x] **M2.1** `domain/czech.Normalize` — port [czech_utils.py](scripts/czech_utils.py) `normalize` (NFKD + combining-mark strip + lowercase) — `20ade6d`
|
||||
- [x] **M2.2** `domain/czech.ParseMonthReferences` — port `parse_month_references` (45 month declensions, range wrap, year inference) — `0a8017f`
|
||||
- [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`
|
||||
- [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`
|
||||
- [ ] **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
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Member modal — raw payments debug list
|
||||
|
||||
## Context
|
||||
|
||||
When a payer's bank message doesn't follow our convention, [`infer_payments.py`](scripts/infer_payments.py) may map the transfer to the wrong period (or none), and today the member detail modal hides this — it only shows the post-allocation, per-month splits produced by [`reconcile()`](scripts/match_payments.py:295). To diagnose these cases the user needs to see the **original sheet rows** that were attributed to a member: full `Amount`, `Inferred Amount`, `Person`, `Purpose`, `Sender`, `Message`, `Bank ID`, `manual fix`. The list should be hidden by default and revealed by a small toggle, since it is only relevant during debugging.
|
||||
|
||||
## Approach
|
||||
|
||||
Reuse the grouping logic that already exists in the [`/payments` route](app.py:540-553): group raw `tx` dicts by parsed `Person`, expose that mapping to the modal, and render it on demand under a new collapsible section.
|
||||
|
||||
### 1. Backend — group raw txs by member
|
||||
|
||||
In [`app.py`](app.py):
|
||||
|
||||
- Factor the existing per-person grouping in [`payments()`](app.py:530-568) into a small helper near the top of the file:
|
||||
```python
|
||||
def group_payments_by_person(transactions):
|
||||
grouped = {}
|
||||
for tx in transactions:
|
||||
person = str(tx.get("person", "")).strip()
|
||||
if not person:
|
||||
continue # unmatched rows are not tied to a member
|
||||
for p in person.split(","):
|
||||
p = re.sub(r"\[\?\]\s*", "", p).strip()
|
||||
if not p:
|
||||
continue
|
||||
grouped.setdefault(p, []).append(tx)
|
||||
for rows in grouped.values():
|
||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||
return grouped
|
||||
```
|
||||
Call it from [`payments()`](app.py:530), [`adults_view()`](app.py:160) and [`juniors_view()`](app.py:326) — the existing `payments()` body collapses to one line.
|
||||
|
||||
- In `adults_view()` and `juniors_view()`, after `transactions = get_cached_data(...)`, build `raw_payments_by_person = group_payments_by_person(transactions)` and pass it to `render_template` as `raw_payments_json=json.dumps(raw_payments_by_person)`.
|
||||
|
||||
- Note: rows where `Person` is empty are skipped on purpose — those have no member to attach to and are already shown by the dashboard's `Unmatched` block.
|
||||
|
||||
### 2. Templates — add a collapsible raw section to the modal
|
||||
|
||||
In [`templates/adults.html`](templates/adults.html) and [`templates/juniors.html`](templates/juniors.html), make the same structural and JS changes (the modal markup is mirrored in both files — `adults.html:677-682` and `juniors.html:658-663`).
|
||||
|
||||
- Inject the new dataset alongside the existing `memberData`:
|
||||
```html
|
||||
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
|
||||
```
|
||||
(next to [`adults.html:696`](templates/adults.html#L696)).
|
||||
|
||||
- Add a new section directly **after** the Payment History block:
|
||||
```html
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">
|
||||
Raw Payments
|
||||
<a href="#" id="rawPaymentsToggle" class="raw-toggle"
|
||||
onclick="toggleRawPayments(event)">[show]</a>
|
||||
</div>
|
||||
<div id="modalRawList" class="tx-list" style="display: none;">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
Add a small CSS rule for `.raw-toggle` (muted color, smaller font, `margin-left: 8px`) — a few lines next to the existing `.modal-section-title` style. Don't restyle the whole modal.
|
||||
|
||||
- In `showMemberDetails(name)`:
|
||||
- Reset the toggle to `[show]` and the `#modalRawList` to `display: none` on every open (so the state doesn't leak between members).
|
||||
- Populate `#modalRawList` from `rawPaymentsByPerson[name] || []`. For each row render: `Date | Purpose` on the meta line, `Amount CZK` (with `Inferred: X CZK` annotation when `inferred_amount` differs from `amount`), `Sender`, `Person` (full string — useful when split between multiple people), `Message`, and a small footer with `Bank ID` and a `[manual fix]` marker if `manual_fix` is truthy. Reuse the existing `tx-item` / `tx-meta` / `tx-main` / `tx-msg` styles to match the rest of the modal.
|
||||
- When the list is empty, render `<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>` (same idiom used at [`adults.html:813`](templates/adults.html#L813)).
|
||||
|
||||
- Add the toggle handler near `closeModal`:
|
||||
```js
|
||||
function toggleRawPayments(ev) {
|
||||
ev.preventDefault();
|
||||
const list = document.getElementById('modalRawList');
|
||||
const link = document.getElementById('rawPaymentsToggle');
|
||||
const hidden = list.style.display === 'none';
|
||||
list.style.display = hidden ? 'block' : 'none';
|
||||
link.textContent = hidden ? '[hide]' : '[show]';
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Why not extend `reconcile()` instead
|
||||
|
||||
`reconcile()` already collapses each row into per-month allocated shares and drops `purpose`, `inferred_amount`, `bank_id`, `manual_fix`, and the gross `amount` ([trace](scripts/match_payments.py:436-469)). Carrying the raw `tx` through `reconcile()` would inflate the contract for every consumer when only the modal needs it. Grouping the already-fetched `transactions` list at the route level is one extra dict per request and reuses the cached payments data — no new sheet reads.
|
||||
|
||||
## Critical files
|
||||
|
||||
- [app.py](app.py) — add `group_payments_by_person()` helper; call it in `adults_view()`, `juniors_view()`, and `payments()`; pass `raw_payments_json` to the two dashboard templates.
|
||||
- [templates/adults.html](templates/adults.html) — modal section + JS + tiny CSS for the toggle link.
|
||||
- [templates/juniors.html](templates/juniors.html) — same changes as adults.html.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `make web-debug` and open `http://localhost:5001/adults`.
|
||||
2. Pick a member known to have multiple payments (use the existing `/payments` page as a cross-reference).
|
||||
3. Click `[i]` → modal opens, raw list is hidden, link shows `[show]`. Click the link → list appears with the raw rows; click again → hides, link returns to `[show]`.
|
||||
4. Switch to another member via keyboard (ArrowDown) — the toggle resets to hidden and the list updates to the new member's rows (no leaking).
|
||||
5. Compare the raw rows in the modal against the `/payments` page grouping for the same person — same set of rows, same `Date`/`Amount`/`Message`.
|
||||
6. Pick a row with a non-conformant message (e.g. one where `Person` was inferred to multiple people) — confirm `Person` shows the full comma-separated string and `Inferred Amount` is visible when it differs from `Amount`.
|
||||
7. Repeat the click-through on `/juniors` to confirm parity.
|
||||
8. `make test` — no backend behavior change is expected, but run to catch template/route smoke breakage.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Tolerate diacritic / case / whitespace mismatches between `Person` column and member names
|
||||
|
||||
## Context
|
||||
|
||||
For "Mária Maco" there is a payment row in the payments sheet with `Purpose = 2026-04`, but the modal for that member shows neither a paid 2026-04 cell **nor** a row in payment history. Both symptoms collapse to a single root cause in [`reconcile()`](scripts/match_payments.py#L295), confirmed by reading the code:
|
||||
|
||||
- [`scripts/match_payments.py:404`](scripts/match_payments.py#L404) — `if member_name not in ledger:` is a **byte-exact** comparison. `member_name` is the `Person` cell from the payments sheet with only `.strip()` and `[?]` markers removed ([:349-353](scripts/match_payments.py#L349-L353)). `ledger` keys are the canonical names from the attendance sheet. There is no diacritic, case, or whitespace normalization on this path. (`czech_utils.normalize` is imported and used for the `exceptions` lookup at [:282-283 / :321-322](scripts/match_payments.py#L282-L322), but **not** for member-name matching.)
|
||||
- When a row falls through that check, it is appended to `unmatched` and never reaches `ledger[member_name][m]['paid']` or `['transactions']`. The dashboard's per-month "paid" cell stays unpaid, and because the modal's payment history is built from `data.months[m].transactions` ([`templates/adults.html:772-776`](templates/adults.html#L772-L776)), the row also disappears from the modal's history list.
|
||||
- The new "Raw Payments" debug section ([`templates/adults.html:861`](templates/adults.html#L861)) uses `rawPaymentsByPerson[name]`. Its keys come from [`group_payments_by_person()` in `app.py:60-73`](app.py#L60-L73), which also stores the **literal** `Person` string (only `.strip()` and `[?]` stripped). So if the attendance-sheet name and the `Person` cell differ at the byte level, that section also returns an empty list — which is why the user does not see the row anywhere in the modal.
|
||||
|
||||
The most likely cause for "Mária Maco" specifically: the `Person` cell was typed (or pasted) without the `á` diacritic — `Maria Maco` vs `Mária Maco`. Other plausible variants the current code silently drops: case differences (`mária maco`), trailing/embedded extra whitespace, and NBSP characters.
|
||||
|
||||
The fix is to make the matching tolerant via the existing [`czech_utils.normalize()`](scripts/czech_utils.py#L22-L25) helper (NFKD + lowercase), with a small whitespace-collapse on top, and apply the same canonicalization in `group_payments_by_person()` so the modal's raw-payments lookup uses the canonical attendance-sheet name as the key.
|
||||
|
||||
## Approach
|
||||
|
||||
### 1. `scripts/match_payments.py` — tolerant `Person` → `ledger` resolution in `reconcile()`
|
||||
|
||||
- Add a small private helper at module scope:
|
||||
|
||||
```python
|
||||
def _canonical_key(name: str) -> str:
|
||||
return re.sub(r"\s+", " ", normalize(name)).strip()
|
||||
```
|
||||
|
||||
Uses the existing `normalize()` from `czech_utils` ([:22-25](scripts/czech_utils.py#L22-L25)) and additionally collapses whitespace runs to a single space so `"Mária Maco"` and `"Mária Maco"` both reduce to `"maria maco"`.
|
||||
|
||||
- Inside [`reconcile()`](scripts/match_payments.py#L295), right after `member_names` is computed ([:308](scripts/match_payments.py#L308)), build a lookup dict once:
|
||||
|
||||
```python
|
||||
canonical_by_key: dict[str, str] = {}
|
||||
for name in member_names:
|
||||
key = _canonical_key(name)
|
||||
canonical_by_key.setdefault(key, name) # first wins; ambiguity handled below
|
||||
```
|
||||
|
||||
- Replace the byte-exact check at [:404](scripts/match_payments.py#L404). Resolve each `member_name` from `matched_members` to the canonical attendance-sheet name before any ledger / credits access:
|
||||
|
||||
```python
|
||||
for raw_member_name, confidence in matched_members:
|
||||
member_name = canonical_by_key.get(_canonical_key(raw_member_name))
|
||||
if member_name is None:
|
||||
logger.warning(
|
||||
"Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
|
||||
raw_member_name, tx.get("date", "?"), tx.get("message", "?"),
|
||||
)
|
||||
unmatched.append(tx)
|
||||
continue
|
||||
if member_name != raw_member_name:
|
||||
logger.info(
|
||||
"Person cell %r resolved to canonical member %r — consider fixing the sheet",
|
||||
raw_member_name, member_name,
|
||||
)
|
||||
# ... rest of the loop body unchanged: ledger[member_name], credits[member_name], …
|
||||
```
|
||||
|
||||
The `logger.info` line lets the user see (in `make web-debug` logs) which sheet rows have a non-canonical `Person` value, so they can clean them up at their own pace — without breaking allocation in the meantime.
|
||||
|
||||
- Leave the rest of the function untouched. Once `member_name` is the canonical name, every downstream key (`ledger[member_name]`, `credits[member_name]`, `other_ledger[member_name]`, the `tx["person"]` echo into `transactions`) is already correct.
|
||||
|
||||
### 2. `app.py` — canonicalize the raw-payments grouping key
|
||||
|
||||
- The current [`group_payments_by_person()`](app.py#L60-L73) cannot canonicalize on its own because it does not know the attendance-sheet member list. Extend its signature to accept the member list and reuse `_canonical_key`:
|
||||
|
||||
```python
|
||||
from match_payments import _canonical_key # or re-export via a tiny public name
|
||||
|
||||
def group_payments_by_person(transactions, member_names=None):
|
||||
canonical_by_key = (
|
||||
{_canonical_key(n): n for n in member_names} if member_names else {}
|
||||
)
|
||||
grouped = {}
|
||||
for tx in transactions:
|
||||
person = str(tx.get("person", "")).strip()
|
||||
if not person:
|
||||
continue
|
||||
for p in person.split(","):
|
||||
p = re.sub(r"\[\?\]\s*", "", p).strip()
|
||||
if not p:
|
||||
continue
|
||||
key = canonical_by_key.get(_canonical_key(p), p) # fallback: keep raw
|
||||
grouped.setdefault(key, []).append(tx)
|
||||
for rows in grouped.values():
|
||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||
return grouped
|
||||
```
|
||||
|
||||
- Update the three call sites to pass `member_names`:
|
||||
- `adults_view()` around [`app.py:333`](app.py#L333) — `members` is already in scope; pass `[name for name, _, _ in members]`.
|
||||
- `juniors_view()` around [`app.py:539`](app.py#L539) — same.
|
||||
- `payments()` around [`app.py:549`](app.py#L549) — same; needs the adult+junior member names so the `/payments` per-person grouping is consistent.
|
||||
|
||||
- Naming: `_canonical_key` starts with an underscore inside `match_payments.py`. To avoid leaking a private symbol, expose it as `canonical_member_key` (no underscore) in `match_payments.py` and import that name from `app.py`.
|
||||
|
||||
### 3. Why not also touch `infer_payments.py`
|
||||
|
||||
`infer_payments.py` already writes canonical attendance-sheet names into the `Person` column (it picks from `member_names`). The bug only manifests when the cell was filled in **manually** by a human (typed without diacritics, different case) or was written by an older inference that has since drifted from a renamed attendance row. Making `reconcile()` tolerant fixes the symptom for both cases without changing inference. The `logger.info` line is sufficient signal for the user to clean up the sheet on their own schedule.
|
||||
|
||||
### 4. Tests
|
||||
|
||||
**4a. Delete obsolete route tests in [tests/test_app.py](tests/test_app.py).** Four tests target Flask routes that no longer exist (the old fee/reconcile pages were merged into `/adults` and `/juniors`); they currently fail with 404. Their coverage is already provided by `test_adults_route`, `test_juniors_route`, and `test_payments_route`. Delete:
|
||||
|
||||
- `test_fees_route` ([tests/test_app.py:22-35](tests/test_app.py#L22-L35)) — hits `/fees`
|
||||
- `test_fees_juniors_route` ([tests/test_app.py:37-55](tests/test_app.py#L37-L55)) — hits `/fees-juniors`
|
||||
- `test_reconcile_route` ([tests/test_app.py:57-81](tests/test_app.py#L57-L81)) — hits `/reconcile`; also asserts a literal `OK` string the merged dashboard no longer renders
|
||||
- `test_reconcile_juniors_route` ([tests/test_app.py:101-131](tests/test_app.py#L101-L131)) — hits `/reconcile-juniors`; same `OK` assertion mismatch
|
||||
|
||||
The two tests that reference junior-only formatting (`? / 1 (J)` and `500 CZK / 4 (1A+3J)`) are testing a retired template, not the live `/juniors` page — no need to migrate those assertions; the live `/juniors` format is already covered by `test_juniors_route`.
|
||||
|
||||
**4b. Add `tests/test_match_payments.py`** (new file) covering the resolution helper and `reconcile()` end-to-end for the canonicalization fix:
|
||||
|
||||
- `_canonical_key("Mária Maco") == _canonical_key("maria maco")`
|
||||
- `reconcile()` with member `"Mária Maco"` and a tx `{person: "Maria Maco", purpose: "2026-04", amount: 750, ...}` produces:
|
||||
- `result['members']['Mária Maco']['months']['2026-04']['paid'] == 750`
|
||||
- the tx appears in `result['members']['Mária Maco']['months']['2026-04']['transactions']`
|
||||
- `result['unmatched']` is empty
|
||||
- `reconcile()` with `Person = "Někdo Neznámý"` (no match in members) still routes to `unmatched`.
|
||||
|
||||
## Critical files
|
||||
|
||||
- [scripts/match_payments.py](scripts/match_payments.py) — add `canonical_member_key()` helper; build `canonical_by_key` once in `reconcile()`; resolve `raw_member_name` → `member_name` before ledger access at [:404](scripts/match_payments.py#L404).
|
||||
- [app.py](app.py) — extend `group_payments_by_person()` to accept `member_names` and key the grouped dict by canonical attendance-sheet name; update three call sites.
|
||||
- [tests/test_app.py](tests/test_app.py) — delete the four obsolete route tests listed in §4a.
|
||||
- [tests/test_match_payments.py](tests/test_match_payments.py) — add the cases above (create the file if missing).
|
||||
- [docs/plans/](docs/plans/) — per project [CLAUDE.md](CLAUDE.md), move this plan file to `docs/plans/2026-05-05-1640-payment-person-name-canonicalization.md` once execution starts (the plan-mode harness writes to `~/.claude/plans/` by default).
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Reproduce first.** Before touching code, open `/adults`, click `[i]` next to "Mária Maco", and confirm both: 2026-04 is unpaid and the payment is missing from history. Inspect the actual `Person` cell value in the payments sheet for the 2026-04 row — confirm it differs from `"Mária Maco"` (likely missing the `á`). Record the exact string for the test case.
|
||||
2. `make test` — new tests pass; existing tests still green.
|
||||
3. `make web-debug` and reload `/adults`. The 2026-04 cell for "Mária Maco" turns green (`cell-ok`); the modal's payment history shows the row; the "Raw Payments" section also shows the row. Server log emits `Person cell 'Maria Maco' resolved to canonical member 'Mária Maco' — consider fixing the sheet`.
|
||||
4. Cross-check `/payments` — the row appears under the `Mária Maco` group (canonical key), not under a separate `Maria Maco` group.
|
||||
5. Spot-check one member with the conventionally-correct `Person` value (e.g. one of the recent payers visible on the dashboard) — paid cells and history are unchanged, no spurious resolution log line.
|
||||
6. Confirm a payment with a genuinely unknown `Person` (typo of a non-member) still ends up in the dashboard's `Unmatched` block and emits the existing `Payment matched to unknown member …` warning.
|
||||
7. Append a `CHANGELOG.md` entry per [CLAUDE.md](CLAUDE.md) once the user confirms the fix works.
|
||||
83
docs/plans/2026-05-05-2144-branch-per-feature-workflow.md
Normal file
83
docs/plans/2026-05-05-2144-branch-per-feature-workflow.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Branch-per-feature + Gitea MR workflow
|
||||
|
||||
## Context
|
||||
|
||||
Until now, Claude has been committing feature work directly to `main`
|
||||
(see recent history: `feat: Lower adult monthly fee…`, `feat: Go rewrite M1…`,
|
||||
all on `main`). The user wants to switch to a branch-per-feature flow with
|
||||
review via a Gitea merge request, so that:
|
||||
|
||||
- Feature work is reviewable as a self-contained diff before it lands.
|
||||
- `main` stays releasable.
|
||||
- The change history shows reviewed merges, not unsupervised pushes.
|
||||
|
||||
The remote is Gitea (`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management.git`),
|
||||
which supports the standard pull/merge-request flow.
|
||||
|
||||
This plan only modifies `CLAUDE.md`. No code changes.
|
||||
|
||||
## Scope clarification (from user)
|
||||
|
||||
- **MR creation method:** Claude pushes the branch and prints the Gitea
|
||||
"compare" URL. The user opens / merges the MR in the browser. No `tea` CLI,
|
||||
no API calls.
|
||||
- **When the flow applies:** Features only. Small bug fixes and hotfixes can
|
||||
still be committed straight to `main`. Claude decides feature-vs-fix based
|
||||
on scope; when uncertain, ask.
|
||||
- **Branch naming:** `feat/<slug>` for features, `fix/<slug>` for the
|
||||
occasional bug-fix branch the user explicitly requests. `<slug>` is
|
||||
kebab-case, short, descriptive.
|
||||
|
||||
## Change
|
||||
|
||||
Add a new top-level section to `CLAUDE.md` titled **"Branching & merge requests"**,
|
||||
placed immediately before the existing `## Git Commits` section so the workflow
|
||||
context appears before the commit-message convention.
|
||||
|
||||
### Proposed section content
|
||||
|
||||
```markdown
|
||||
## Branching & merge requests
|
||||
|
||||
The remote is Gitea (`gitea.home.hrajfrisbee.cz/kacerr/fuj-management`).
|
||||
For **features**, do not commit to `main` directly. Use a branch + merge
|
||||
request flow:
|
||||
|
||||
1. **Create a branch off `main`** before starting work:
|
||||
- `feat/<slug>` for features (e.g. `feat/qr-code-overlay`)
|
||||
- `fix/<slug>` for bug-fix branches the user explicitly asks for
|
||||
- `<slug>` is short kebab-case
|
||||
2. **Commit on the branch** following the existing commit conventions
|
||||
(Co-Authored-By trailer, etc.).
|
||||
3. **Push the branch** to `origin` with `-u` so it tracks.
|
||||
4. **Print the Gitea compare URL** so the user can open the MR in the
|
||||
browser:
|
||||
`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...<branch>`
|
||||
Do **not** use `tea`, `gh`, or call the Gitea API — the user opens and
|
||||
merges the MR themselves.
|
||||
5. **Do not merge or delete the branch** from the CLI. The user does that
|
||||
in Gitea.
|
||||
|
||||
**Exceptions — when committing straight to `main` is fine:**
|
||||
- Small bug fixes / hotfixes the user describes as such.
|
||||
- Typo / comment / formatting tweaks.
|
||||
- Edits the user explicitly says to push to `main`.
|
||||
|
||||
When uncertain whether something is "feature" or "small fix", ask before
|
||||
committing.
|
||||
```
|
||||
|
||||
## Files to modify
|
||||
|
||||
- [CLAUDE.md](CLAUDE.md) — insert the new `## Branching & merge requests`
|
||||
section just above the existing `## Git Commits` section (around line 95).
|
||||
|
||||
## Verification
|
||||
|
||||
- Re-read `CLAUDE.md` and confirm the new section is well-placed and the
|
||||
existing structure (`## Git Commits`, `## Changelog`, `## Plans`) is intact.
|
||||
- `git diff CLAUDE.md` should show only an additive change.
|
||||
- No code, tests, or runtime behavior changes — nothing else to test.
|
||||
- Behavior verification happens on the **next** feature request: Claude
|
||||
should create a `feat/<slug>` branch, commit there, push, and print the
|
||||
compare URL instead of committing on `main`.
|
||||
154
docs/plans/2026-05-05-2204-go-rewrite-m2-1-czech-normalize.md
Normal file
154
docs/plans/2026-05-05-2204-go-rewrite-m2-1-czech-normalize.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Plan: Go rewrite — M2.1 `domain/czech.Normalize`
|
||||
|
||||
## Context
|
||||
|
||||
The Go rewrite finished M1 (skeleton, tooling, hello server) in commit
|
||||
`cf0f176` on 2026-05-04. The next milestone, **M2 — Pure-domain helpers**,
|
||||
is current per [progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||
but has no work landed yet (all 12 sub-tasks unchecked).
|
||||
|
||||
This plan covers only the **first** M2 task: porting Python's
|
||||
`normalize` from [scripts/czech_utils.py](../../scripts/czech_utils.py)
|
||||
to Go as `internal/domain/czech.Normalize`. It is the lowest-level helper
|
||||
in the domain — `parse_month_references`, `_build_name_variants`,
|
||||
`match_members`, exception keys, and `reconcile` all transitively depend
|
||||
on it. Getting it byte-equivalent first removes a class of "why does my
|
||||
match not fire" failures from every later M2 task.
|
||||
|
||||
**Decision (confirmed in plan-mode Q):** start with hand-written Go unit
|
||||
tests for fresh Czech edge cases. Defer parity-fixture wiring until
|
||||
M3.1/M3.2 land (separate task); add the parity test for `Normalize`
|
||||
retroactively at that point.
|
||||
|
||||
## Scope
|
||||
|
||||
- New package `go/internal/domain/czech/` with `Normalize` and unit tests.
|
||||
- Add `golang.org/x/text` dependency to `go/go.mod` (currently zero deps).
|
||||
- **Out of scope:** `ParseMonthReferences` (M2.2), fixture tooling
|
||||
(M3.1/M3.2), CLI subcommand wiring (M2.11/M2.12), parity test runner.
|
||||
|
||||
## Recommended approach
|
||||
|
||||
### Python contract to match
|
||||
|
||||
```python
|
||||
def normalize(text: str) -> str:
|
||||
nfkd = unicodedata.normalize("NFKD", text)
|
||||
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower()
|
||||
```
|
||||
|
||||
Three semantic operations:
|
||||
1. NFKD decompose
|
||||
2. Drop characters where `unicodedata.combining(c)` is non-zero
|
||||
3. Lowercase
|
||||
|
||||
### Go implementation
|
||||
|
||||
`go/internal/domain/czech/normalize.go`:
|
||||
|
||||
```go
|
||||
package czech
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func Normalize(s string) string {
|
||||
decomposed := norm.NFKD.String(s)
|
||||
var b strings.Builder
|
||||
b.Grow(len(decomposed))
|
||||
for _, r := range decomposed {
|
||||
if unicode.In(r, unicode.Mn) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.ToLower(b.String())
|
||||
}
|
||||
```
|
||||
|
||||
**Two precision points worth flagging:**
|
||||
|
||||
1. **`unicode.Mn` not `unicode.IsMark`.** The plan's library-choices
|
||||
table mentions `unicode.IsMark`, but that covers Mn + Mc + Me. Python
|
||||
`unicodedata.combining()` returns 0 for Mc/Me (their canonical
|
||||
combining class is 0), so it effectively filters only Mn. Use
|
||||
`unicode.In(r, unicode.Mn)` for byte-equivalence with Python. Cite
|
||||
this in a one-line code comment; it's the kind of thing a future
|
||||
reader will second-guess.
|
||||
2. **`strings.ToLower` vs Go's locale-aware tools.** Python's `.lower()`
|
||||
on already-decomposed Latin is straight ASCII lowercase for Czech.
|
||||
Stdlib `strings.ToLower` matches; do not pull in `golang.org/x/text/cases`.
|
||||
|
||||
### Tests
|
||||
|
||||
`go/internal/domain/czech/normalize_test.go` — table-driven, covers:
|
||||
|
||||
- ASCII passthrough: `"Honza" → "honza"`
|
||||
- Czech lowercase diacritics: `"žluťoučký" → "zlutoucky"`
|
||||
- Mixed case + diacritics: `"Příliš" → "prilis"`
|
||||
- Czech caron + ring: `"Dvořák" → "dvorak"`, `"Růžena" → "ruzena"`
|
||||
- Hard letters: `"Čeněk" → "cenek"`, `"Kačer" → "kacer"`
|
||||
- Empty string: `"" → ""`
|
||||
- Already-normalized: `"prilis" → "prilis"` (idempotence)
|
||||
- Pre-composed vs decomposed input both produce the same output (NFC
|
||||
`"é"` and `"é"` both → `"e"`)
|
||||
- Whitespace preserved: `"Jan Novák" → "jan novak"`
|
||||
|
||||
Run a one-shot cross-check against the live Python implementation for
|
||||
each test input before locking the table:
|
||||
```
|
||||
PYTHONPATH=scripts:. python -c \
|
||||
'from czech_utils import normalize; print(repr(normalize("Dvořák")))'
|
||||
```
|
||||
This is the manual stand-in for the M3 parity fixtures.
|
||||
|
||||
### Wire-up
|
||||
|
||||
- `go get golang.org/x/text@latest` (run from `go/`); `go mod tidy`.
|
||||
- No CLI changes — `cmd/fuj` already stubs `fees`/`reconcile` with
|
||||
exit code 2; no need to touch dispatcher for this task. `Normalize`
|
||||
is consumed by other domain code, not by users directly.
|
||||
|
||||
## Critical files
|
||||
|
||||
- New: [go/internal/domain/czech/normalize.go](../../go/internal/domain/czech/normalize.go)
|
||||
- New: [go/internal/domain/czech/normalize_test.go](../../go/internal/domain/czech/normalize_test.go)
|
||||
- Modified: [go/go.mod](../../go/go.mod), `go/go.sum` (new)
|
||||
- Reference (read-only): [scripts/czech_utils.py](../../scripts/czech_utils.py) — the porting source
|
||||
- Reference (read-only): [docs/plans/2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) — risk #3 (NFKD edge cases)
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end checks before marking M2.1 done:
|
||||
|
||||
1. `cd go && go build ./...` — clean compile.
|
||||
2. `cd go && go test ./internal/domain/czech/...` — all table cases green.
|
||||
3. `cd go && go test -race ./...` — race-clean.
|
||||
4. `cd go && golangci-lint run` (or `make go-lint` from repo root) — clean.
|
||||
5. **Spot parity** (manual, will be automated in M3): for each Go test
|
||||
input, run the Python `normalize` via `PYTHONPATH=scripts:. python -c
|
||||
'...'` and confirm bytes match. Capture the diff in the commit
|
||||
message if anything surprises.
|
||||
6. `make go-build && make go-test && make go-lint` from repo root — proves
|
||||
the existing M1 gate still passes.
|
||||
|
||||
## Branching & follow-up
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md), this is feature work → branch + Gitea MR:
|
||||
|
||||
- Branch: `feat/m2-1-czech-normalize` off `main`.
|
||||
- Single commit, Co-Authored-By trailer.
|
||||
- Push with `-u`, print compare URL
|
||||
`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...feat/m2-1-czech-normalize`
|
||||
- User opens/merges the MR.
|
||||
- After merge: tick `M2.1` in the progress tracker with the commit SHA;
|
||||
add a one-line CHANGELOG entry; record any porting surprise in the
|
||||
tracker's "Notes & decisions" section (e.g. the `Mn`-vs-`IsMark`
|
||||
precision point if it bears noting).
|
||||
|
||||
Next task after this lands is **M2.2 `ParseMonthReferences`** — the
|
||||
larger, edge-case-heavier sibling. Whether to start it before or after
|
||||
M3.1/M3.2 is a separate decision the user can make then.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Plan: Go rewrite — M2.2 `domain/czech.ParseMonthReferences`
|
||||
|
||||
## Context
|
||||
|
||||
M2.1 (`domain/czech.Normalize`) merged via PR #4 (`d9a61b3`) on
|
||||
2026-05-05. Per the [progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md),
|
||||
**M2.2** is next: port `parse_month_references` from
|
||||
[scripts/czech_utils.py](../../scripts/czech_utils.py) to Go as
|
||||
`internal/domain/czech.ParseMonthReferences`.
|
||||
|
||||
This function is the second-most-load-bearing pure helper after
|
||||
`reconcile`: every payment-message → month inference goes through it.
|
||||
Risk #4 in the [parent plan](2026-05-03-2349-go-backend-rewrite.md)
|
||||
specifically calls out its semantics — wrap-around year inference and
|
||||
the `m >= 10 → previous year` standalone heuristic — as easy to mis-port.
|
||||
|
||||
This plan locks the test table against the live Python implementation
|
||||
*before* coding, so the Go port has a verified parity baseline even
|
||||
before the M3.1/M3.2 fixture infrastructure exists.
|
||||
|
||||
## Scope
|
||||
|
||||
- New file `go/internal/domain/czech/parse_month_references.go` in the
|
||||
existing `czech` package (alongside [normalize.go](../../go/internal/domain/czech/normalize.go)).
|
||||
- New file `go/internal/domain/czech/parse_month_references_test.go`
|
||||
with the test table below.
|
||||
- **Out of scope:** parity-fixture wiring (M3.1/M3.2); CLI hook-up
|
||||
(M2.11/M2.12); any consumer call-sites.
|
||||
- **No new dependencies** — stdlib `regexp`, `sort`, `strconv`, `strings`
|
||||
plus the existing `czech.Normalize` cover everything.
|
||||
|
||||
## Recommended approach
|
||||
|
||||
### Python contract to mirror
|
||||
|
||||
Three regex passes, all run on `normalize(text)`:
|
||||
|
||||
1. `([\d+]+)\s*/\s*(\d{2,4})` — captures `"11+12/2025"`, `"01/26"`, `"1/26"`.
|
||||
Split the months part on `+`, keep digit-only tokens, validate `1..12`.
|
||||
Year < 100 → year + 2000.
|
||||
2. `(\d{1,2})\s*\.\s*(\d{4})` — captures `"12.2025"`. **4-digit year only**
|
||||
(so `"1.26"` does not match).
|
||||
3. Czech month names. First the **range** sub-pass:
|
||||
`(name)\s*-\s*(name)` finds pairs; walk start→end with `m % 12 + 1`,
|
||||
stopping when `m == end_m`. Wrap rule: if `start_m > end_m`, months
|
||||
`>= start_m` are `defaultYear - 1`, the rest are `defaultYear`. Both
|
||||
matched names go into a `foundInRanges` set.
|
||||
Then the **standalone** sub-pass: `\b(name)\b`, skipping any name in
|
||||
`foundInRanges`. For each remaining match, `m >= 10 → defaultYear - 1`,
|
||||
else `defaultYear`.
|
||||
|
||||
Output: sorted, deduplicated `[]string` of `"YYYY-MM"`.
|
||||
|
||||
### Go signature
|
||||
|
||||
```go
|
||||
package czech
|
||||
|
||||
// ParseMonthReferences extracts YYYY-MM month references from Czech
|
||||
// free text. defaultYear seeds two heuristics: standalone month names
|
||||
// with m >= 10 are treated as defaultYear-1 (out-of-year backfill), and
|
||||
// wrap-around ranges (e.g. listopad-leden) place months >= start in
|
||||
// defaultYear-1.
|
||||
func ParseMonthReferences(text string, defaultYear int) []string
|
||||
```
|
||||
|
||||
Required `defaultYear` (no default value — Go convention).
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
```go
|
||||
var czechMonths = map[string]int{
|
||||
"leden": 1, "ledna": 1, "lednu": 1,
|
||||
"unor": 2, "unora": 2, "unoru": 2,
|
||||
"brezen": 3, "brezna": 3, "breznu": 3,
|
||||
"duben": 4, "dubna": 4, "dubnu": 4,
|
||||
"kveten": 5, "kvetna": 5, "kvetnu": 5,
|
||||
"cerven": 6, "cervna": 6, "cervnu": 6,
|
||||
"cervenec": 7, "cervnce": 7, "cervenci": 7,
|
||||
"srpen": 8, "srpna": 8, "srpnu": 8,
|
||||
"zari": 9,
|
||||
"rijen": 10, "rijna": 10, "rijnu": 10,
|
||||
"listopad": 11, "listopadu": 11,
|
||||
"prosinec": 12, "prosince": 12, "prosinci": 12,
|
||||
}
|
||||
|
||||
// Sorted by descending length at init, so longer alternatives win in
|
||||
// the regex (e.g. "cervenec" beats "cerven"). Mirrors Python's
|
||||
// sorted(..., key=len, reverse=True).
|
||||
var monthNameAlt = buildMonthNameAlt()
|
||||
|
||||
var (
|
||||
numericRe = regexp.MustCompile(`([\d+]+)\s*/\s*(\d{2,4})`)
|
||||
dotRe = regexp.MustCompile(`(\d{1,2})\s*\.\s*(\d{4})`)
|
||||
rangeRe = regexp.MustCompile(`(` + monthNameAlt + `)\s*-\s*(` + monthNameAlt + `)`)
|
||||
standRe = regexp.MustCompile(`\b(` + monthNameAlt + `)\b`)
|
||||
)
|
||||
```
|
||||
|
||||
Three Go-specific gotchas worth a code comment:
|
||||
|
||||
1. **RE2 alternation is leftmost-first**, same as Python `re`. Sorting
|
||||
month names by descending length is therefore necessary (otherwise
|
||||
`"cervenec"` matches as `"cerven"` + leftover `"ec"`). Mirror the
|
||||
Python sort exactly.
|
||||
2. **Map iteration is randomized in Go.** Build the alternation list
|
||||
from a sorted slice of keys, not by iterating the map.
|
||||
3. **`\d` and `\b`** in Go RE2 are ASCII-only, which matches the
|
||||
effective behavior on `Normalize`'d input (NFKD already collapsed
|
||||
any Unicode digits/letters that would matter; standalone Devanagari
|
||||
digits in member messages aren't a real-world concern).
|
||||
|
||||
The walk loop uses a bounded counter (max 12 iterations) defensively in
|
||||
Go; Python's `while True` is fine because every range terminates within
|
||||
12 hops, but a future reader appreciates the bound.
|
||||
|
||||
### Test table (verified against live Python — `default_year=2026`)
|
||||
|
||||
Locked outputs from `PYTHONPATH=scripts:. python -c 'from czech_utils
|
||||
import parse_month_references; print(parse_month_references(<input>, 2026))'`
|
||||
on 2026-05-05.
|
||||
|
||||
| # | Input | Expected | Path exercised |
|
||||
|---|---|---|---|
|
||||
| 1 | `""` | `[]` | empty |
|
||||
| 2 | `"11+12/2025"` | `["2025-11", "2025-12"]` | numeric, plus-split |
|
||||
| 3 | `"1/2026"` | `["2026-01"]` | numeric, single |
|
||||
| 4 | `"01/26"` | `["2026-01"]` | 2-digit year normalization |
|
||||
| 5 | `"11+12/25"` | `["2025-11", "2025-12"]` | plus-split + 2-digit year |
|
||||
| 6 | `"12+1+2/2026"` | `["2026-01", "2026-02", "2026-12"]` | sorting |
|
||||
| 7 | `"12.2025"` | `["2025-12"]` | dot pattern |
|
||||
| 8 | `"1.26"` | `[]` | dot pattern requires 4-digit year |
|
||||
| 9 | `"leden"` | `["2026-01"]` | standalone, m<10 |
|
||||
| 10 | `"prosinec"` | `["2025-12"]` | standalone, m≥10 → previous year |
|
||||
| 11 | `"prosince"` | `["2025-12"]` | declension |
|
||||
| 12 | `"lednu"` | `["2026-01"]` | declension |
|
||||
| 13 | `"rijen"` | `["2025-10"]` | m≥10 boundary (10 itself) |
|
||||
| 14 | `"zari"` | `["2026-09"]` | m<10 just below boundary |
|
||||
| 15 | `"listopad-leden"` | `["2025-11", "2025-12", "2026-01"]` | wrap range Nov→Jan |
|
||||
| 16 | `"rijen-leden"` | `["2025-10", "2025-11", "2025-12", "2026-01"]` | wrap from October |
|
||||
| 17 | `"unor-kveten"` | `["2026-02", "2026-03", "2026-04", "2026-05"]` | non-wrap range |
|
||||
| 18 | `"leden-leden"` | `["2026-01"]` | degenerate range |
|
||||
| 19 | `"unor-listopad"` | `["2026-02", ..., "2026-11"]` (10 entries) | range spans m≥10 — heuristic does NOT fire (range exclusion) |
|
||||
| 20 | `"cervenec-srpen"` | `["2026-07", "2026-08"]` | longest-match alt (`cervenec` not `cerven`+`ec`) |
|
||||
| 21 | `"listopad-leden, prosinec"` | `["2025-11", "2025-12", "2026-01"]` | range + standalone, dedup |
|
||||
| 22 | `"prosinec leden"` | `["2025-12", "2026-01"]` | two standalones, no range |
|
||||
| 23 | `"11+12/2025, leden-brezen"` | `["2025-11", "2025-12", "2026-01", "2026-02", "2026-03"]` | numeric + range mix |
|
||||
| 24 | `"11+12/25 a listopad"` | `["2025-11", "2025-12"]` | dedup across passes |
|
||||
| 25 | `"prosince/2025"` | `["2025-12"]` | numeric pattern fails (no digits before `/`); standalone fires |
|
||||
| 26 | `"listopad-prosinec/2025"` | `["2026-11", "2026-12"]` | range wins; numeric pattern fails |
|
||||
| 27 | `"01.2026 / 02.2026"` | `["2026-01", "2026-02"]` | dot pattern only; numeric matches `(2026, 02)` but month 2026 is out of range |
|
||||
| 28 | `"/12/2025"` | `["2025-12"]` | numeric matches at second `/` |
|
||||
| 29 | `"PROSINEC"` | `["2025-12"]` | normalize lowercases |
|
||||
| 30 | `"Žluťoučký prosinec"` | `["2025-12"]` | normalize strips diacritics |
|
||||
| 31 | `"Únor - květen"` | `["2026-02", ..., "2026-05"]` | range tolerates spaces around `-`, diacritics survive normalize |
|
||||
| 32 | `"platba 11/2025 a leden"` | `["2025-11", "2026-01"]` | mixed natural-language |
|
||||
| 33 | `"December"` | `[]` | English month names not recognized |
|
||||
| 34 | `"11+12/2025 11+12/2025"` | `["2025-11", "2025-12"]` | dedup of repeated input |
|
||||
| 35 | `"leden 2026"` | `["2026-01"]` | trailing year is ignored unless dot/slash separator present |
|
||||
|
||||
35 cases is enough to lock semantics; the M3.x corpus will pile on
|
||||
real-message fixtures later.
|
||||
|
||||
### Wire-up
|
||||
|
||||
- No `go.mod` changes (stdlib only).
|
||||
- No CLI changes.
|
||||
- `Normalize` is in the same package, so call it directly.
|
||||
|
||||
## Critical files
|
||||
|
||||
- New: [go/internal/domain/czech/parse_month_references.go](../../go/internal/domain/czech/parse_month_references.go)
|
||||
- New: [go/internal/domain/czech/parse_month_references_test.go](../../go/internal/domain/czech/parse_month_references_test.go)
|
||||
- Reference (read-only): [scripts/czech_utils.py](../../scripts/czech_utils.py) — the porting source
|
||||
- Reference (read-only): [docs/plans/2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) — risk #4
|
||||
- Reuses: [go/internal/domain/czech/normalize.go](../../go/internal/domain/czech/normalize.go) — `Normalize` is called once at the top of `ParseMonthReferences`
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end checks before marking M2.2 done:
|
||||
|
||||
1. `cd go && go build ./...` — clean compile.
|
||||
2. `cd go && go test ./internal/domain/czech/...` — all 35 table cases green.
|
||||
3. `cd go && go test -race ./...` — race-clean (regex compiles are global; verify no init races).
|
||||
4. `cd go && golangci-lint run` (or `make go-lint` from repo root) — clean, gofumpt-formatted.
|
||||
5. **Spot parity** (manual, will be automated in M3.x): each test input has its expected output captured from the live Python implementation on 2026-05-05; the test table itself is the parity record. If any case diverges during implementation, re-run Python with the exact input to confirm the truth and update either the Go code or the test entry.
|
||||
6. `make go-build && make go-test && make go-lint` from repo root — proves M1/M2.1 gate still passes.
|
||||
|
||||
## Branching & follow-up
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md), this is feature work → branch + Gitea MR via `tea`:
|
||||
|
||||
- Branch: `feat/m2-2-parse-month-references` off `main`.
|
||||
- Single focused commit, Co-Authored-By trailer.
|
||||
- Push with `-u`.
|
||||
- Open MR with `tea pr create --title "feat(go/M2.2): port czech.ParseMonthReferences" --description ... --base main --head feat/m2-2-parse-month-references`. Print the MR URL for the user.
|
||||
- User merges/deletes the branch in Gitea — never from the CLI.
|
||||
|
||||
After merge (small doc edits land straight on `main` per CLAUDE.md exception):
|
||||
|
||||
- Tick `M2.2` in the [progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md) with the merge SHA.
|
||||
- Add a one-line `CHANGELOG.md` entry (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
|
||||
- Record any porting surprise (e.g. an unexpected diff between Go RE2 and Python `re`) in the tracker's "Notes & decisions" section.
|
||||
|
||||
Next task is **M2.3 `domain/fees.CalculateFee`** — straightforward constants table; no parser semantics to debate.
|
||||
199
docs/plans/2026-05-06-0928-go-m2-5-money-parse-czk.md
Normal file
199
docs/plans/2026-05-06-0928-go-m2-5-money-parse-czk.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# M2.5 — Port `parse_czk_amount` to `domain/money.ParseCZK`
|
||||
|
||||
> On execution, this plan should be moved to
|
||||
> `docs/plans/2026-05-06-0928-go-m2-5-money-parse-czk.md` per project CLAUDE.md
|
||||
> (`docs/plans/YYYY-MM-DD-HHMM-<slug>.md`). Plan mode forces it to live under
|
||||
> `~/.claude/plans/` until then.
|
||||
|
||||
## 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.4 are landed. Next leaf-level pure function is
|
||||
`parse_czk_amount` from [scripts/infer_payments.py:17-45](../../srv/personal/fuj-management/scripts/infer_payments.py#L17-L45),
|
||||
the Czech-locale amount parser used at [scripts/infer_payments.py:124](../../srv/personal/fuj-management/scripts/infer_payments.py#L124)
|
||||
when reading the `Inferred Amount` column out of the payments sheet.
|
||||
|
||||
It's a small, isolated string→float helper, but its heuristic for
|
||||
disambiguating `.` and `,` as decimal vs thousand separator is
|
||||
non-obvious and needs to behave identically in Go to keep parity once
|
||||
the Go infer pipeline lands in M4.8.
|
||||
|
||||
## Python behaviour (the spec)
|
||||
|
||||
```py
|
||||
def parse_czk_amount(val) -> float:
|
||||
if val is None or val == "":
|
||||
return 0.0
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
|
||||
val = str(val)
|
||||
val = val.replace("Kč", "").replace("CZK", "").strip()
|
||||
if "," in val:
|
||||
# 1.500,00 -> 1500.00 — comma is decimal sep
|
||||
val = val.replace(".", "").replace(" ", "").replace(",", ".")
|
||||
else:
|
||||
if val.count(".") > 1:
|
||||
# 1.500.000 -> 1500000 — multiple dots = thousand sep
|
||||
val = val.replace(".", "").replace(" ", "")
|
||||
else:
|
||||
# "1 500.00" -> "1500.00", "1.500" stays "1.500" (= 1.5)
|
||||
val = val.replace(" ", "")
|
||||
try:
|
||||
return float(val)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
```
|
||||
|
||||
Key behavioural notes for the Go port:
|
||||
|
||||
1. Empty / None → 0, no error.
|
||||
2. `"1.500"` (single dot, no comma) is parsed as **1.5**, not 1500.
|
||||
The heuristic intentionally treats a lone dot as decimal.
|
||||
3. `"1.500,00"` → 1500.0 (comma wins, dots are thousand seps).
|
||||
4. `"1.500.000"` → 1500000.0 (multiple dots → all thousand seps).
|
||||
5. `"1 500"` / `"1 500.00"` / `"500 Kč"` → spaces stripped.
|
||||
6. Garbage → 0, no error in Python.
|
||||
7. Strips literal substrings `"Kč"` and `"CZK"` (case-sensitive in Python).
|
||||
|
||||
## Approach
|
||||
|
||||
Create new package `internal/domain/money` mirroring the layout of
|
||||
`internal/domain/fees` (single-file module + test file alongside).
|
||||
|
||||
### Signature
|
||||
|
||||
```go
|
||||
// Package money ports Czech-locale currency parsing from
|
||||
// scripts/infer_payments.py.
|
||||
package money
|
||||
|
||||
// ParseCZK parses a Czech-locale amount string and returns the value
|
||||
// in CZK as a float64.
|
||||
//
|
||||
// Mirrors scripts/infer_payments.py parse_czk_amount:
|
||||
// - empty input → (0, nil)
|
||||
// - "Kč"/"CZK" suffixes are stripped (case-sensitive, like Python)
|
||||
// - if input contains ",", comma is the decimal separator and
|
||||
// dots/spaces are thousand separators ("1.500,00" → 1500.0)
|
||||
// - else if input contains 2+ dots, all dots are thousand seps
|
||||
// ("1.500.000" → 1500000.0)
|
||||
// - else single dot stays as the decimal point ("1.500" → 1.5,
|
||||
// matching the Python heuristic)
|
||||
// - on parse failure, returns (0, ErrInvalidAmount). Callers wanting
|
||||
// Python-equivalent silent-zero behaviour can discard the error.
|
||||
func ParseCZK(s string) (float64, error)
|
||||
```
|
||||
|
||||
`ErrInvalidAmount` is a package-level sentinel:
|
||||
|
||||
```go
|
||||
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
|
||||
```
|
||||
|
||||
Why `(float64, error)` instead of mirroring Python's silent zero:
|
||||
|
||||
- Go idiom prefers explicit errors.
|
||||
- The single Python call site doesn't distinguish parse-fail from
|
||||
empty-input (both → 0), so if we want byte-equal behaviour at the
|
||||
Go infer site (M4.8), the caller can `v, _ := money.ParseCZK(s)`
|
||||
and get exactly the Python result.
|
||||
- Future callers (e.g. user-facing import flows) may want to surface
|
||||
the error.
|
||||
|
||||
This matches the precedent set in M2.4 where we used
|
||||
`Expected{Unknown bool}` rather than copying the Python `"?"` sentinel
|
||||
verbatim — Go-idiomatic surface, parity-preserving semantics.
|
||||
|
||||
### Polymorphic input?
|
||||
|
||||
Python's `parse_czk_amount` also accepts raw int/float (passed through
|
||||
unchanged) because Google Sheets API can return numeric cells as
|
||||
`float64` rather than strings. **Skip this in Go.** The Sheets IO
|
||||
adapter is M4.2, and that's where the `[]any` → string normalisation
|
||||
will live. Keeping `ParseCZK` string-only keeps the leaf function tiny.
|
||||
|
||||
### Tests
|
||||
|
||||
`money_test.go` mirrors the existing `fees_test.go` table-driven style,
|
||||
including the verification comment showing the Python command used to
|
||||
confirm each expected value:
|
||||
|
||||
```sh
|
||||
PYTHONPATH=scripts:. python -c '
|
||||
from infer_payments import parse_czk_amount
|
||||
for v in [None, "", "0", "500", "500 Kč", "500 CZK",
|
||||
"1 500", "1500.00", "1 500.00",
|
||||
"1.500,00", "1500,5", "1.500.000",
|
||||
"1.500", "abc", " ", "100,5 Kč"]:
|
||||
print(repr(v), "->", parse_czk_amount(v))
|
||||
'
|
||||
```
|
||||
|
||||
Cases to cover (all numeric outputs verified against the Python output
|
||||
of the snippet above):
|
||||
|
||||
| input | expected |
|
||||
|---|---|
|
||||
| `""` | 0 |
|
||||
| `"0"` | 0 |
|
||||
| `"500"` | 500 |
|
||||
| `"500 Kč"` | 500 |
|
||||
| `"500 CZK"` | 500 |
|
||||
| `"1 500"` | 1500 |
|
||||
| `"1500.00"` | 1500 |
|
||||
| `"1 500.00"` | 1500 |
|
||||
| `"1.500,00"` | 1500 |
|
||||
| `"1500,5"` | 1500.5 |
|
||||
| `"1.500.000"` | 1500000 |
|
||||
| `"1.500"` | 1.5 *(heuristic — single dot = decimal)* |
|
||||
| `"100,5 Kč"` | 100.5 |
|
||||
| `"abc"` | 0, returns `ErrInvalidAmount` |
|
||||
| `" "` | 0, returns `ErrInvalidAmount` *(or 0 nil — confirm against Python; trim leaves `""`, then `float("")` raises → Python returns 0; Go test will assert whichever Python actually produces)* |
|
||||
|
||||
The `" "` row is the only one that needs the Python verification step
|
||||
to settle — once verified, lock the behaviour in.
|
||||
|
||||
Also add a "documentation example" assertion in the test that
|
||||
`v, _ := ParseCZK(s)` recovers the Python silent-zero contract for
|
||||
every garbage input, so we don't lose that property at the Go infer
|
||||
call site.
|
||||
|
||||
## Files to create
|
||||
|
||||
- `go/internal/domain/money/money.go` — package + `ParseCZK` + `ErrInvalidAmount`
|
||||
- `go/internal/domain/money/money_test.go` — table-driven tests
|
||||
|
||||
No existing Go files need editing.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
cd go && go test ./internal/domain/money/...
|
||||
make go-lint
|
||||
make go-build # sanity: nothing else broke
|
||||
```
|
||||
|
||||
Also run the Python snippet from the Tests section above and diff its
|
||||
output against the test table to confirm parity.
|
||||
|
||||
## Out of scope (explicit non-goals)
|
||||
|
||||
- Polymorphic `any` input — leave for M4.2 IO adapter.
|
||||
- Hooking into the Tier-1 parity runner — that comes with M3.5
|
||||
(`-tags=parity` build constraint). M2.5 just needs unit tests.
|
||||
- Any callsite migration — `infer_payments.py` keeps using its own
|
||||
Python function until M4.8.
|
||||
|
||||
## Progress tracker + changelog
|
||||
|
||||
After the commit lands:
|
||||
|
||||
- Tick `M2.5` 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.4 entry style.
|
||||
- Add a CHANGELOG.md entry at top:
|
||||
`## YYYY-MM-DD HH:MM TZ — feat(go/M2.5): port domain/money.ParseCZK`.
|
||||
|
||||
Branch: `feat/m2-5-money-parse-czk` (per CLAUDE.md branch-per-feature
|
||||
workflow). Push, open MR via `tea pr create`, leave merge to the user.
|
||||
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.
|
||||
@@ -1,3 +1,5 @@
|
||||
module fuj-management/go
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require golang.org/x/text v0.36.0
|
||||
|
||||
2
go/go.sum
Normal file
2
go/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
26
go/internal/domain/czech/normalize.go
Normal file
26
go/internal/domain/czech/normalize.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package czech
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// Normalize strips diacritics and lowercases s.
|
||||
//
|
||||
// Matches Python: unicodedata.normalize("NFKD", s) then filter out
|
||||
// combining characters (unicode.Mn only — not Mc/Me, which have
|
||||
// combining class 0 in Python's unicodedata.combining()).
|
||||
func Normalize(s string) string {
|
||||
decomposed := norm.NFKD.String(s)
|
||||
var b strings.Builder
|
||||
b.Grow(len(decomposed))
|
||||
for _, r := range decomposed {
|
||||
if unicode.In(r, unicode.Mn) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.ToLower(b.String())
|
||||
}
|
||||
31
go/internal/domain/czech/normalize_test.go
Normal file
31
go/internal/domain/czech/normalize_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package czech
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"Honza", "honza"},
|
||||
{"žluťoučký", "zlutoucky"},
|
||||
{"Příliš", "prilis"},
|
||||
{"Dvořák", "dvorak"},
|
||||
{"Růžena", "ruzena"},
|
||||
{"Čeněk", "cenek"},
|
||||
{"Kačer", "kacer"},
|
||||
{"", ""},
|
||||
{"prilis", "prilis"}, // idempotent
|
||||
{"Jan Novák", "jan novak"}, // whitespace preserved
|
||||
{"é", "e"}, // precomposed é (NFC)
|
||||
{"é", "e"}, // decomposed e + combining acute
|
||||
{"Ondřej Procházka", "ondrej prochazka"}, // realistic full name
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := Normalize(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
154
go/internal/domain/czech/parse_month_references.go
Normal file
154
go/internal/domain/czech/parse_month_references.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package czech
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var czechMonths = map[string]int{
|
||||
"leden": 1, "ledna": 1, "lednu": 1,
|
||||
"unor": 2, "unora": 2, "unoru": 2,
|
||||
"brezen": 3, "brezna": 3, "breznu": 3,
|
||||
"duben": 4, "dubna": 4, "dubnu": 4,
|
||||
"kveten": 5, "kvetna": 5, "kvetnu": 5,
|
||||
"cerven": 6, "cervna": 6, "cervnu": 6,
|
||||
"cervenec": 7, "cervnce": 7, "cervenci": 7,
|
||||
"srpen": 8, "srpna": 8, "srpnu": 8,
|
||||
"zari": 9,
|
||||
"rijen": 10, "rijna": 10, "rijnu": 10,
|
||||
"listopad": 11, "listopadu": 11,
|
||||
"prosinec": 12, "prosince": 12, "prosinci": 12,
|
||||
}
|
||||
|
||||
var (
|
||||
numericRe *regexp.Regexp
|
||||
dotRe *regexp.Regexp
|
||||
rangeRe *regexp.Regexp
|
||||
standRe *regexp.Regexp
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Sort by descending length so longer alternatives win in RE2 leftmost-first
|
||||
// matching (e.g. "cervenec" is tried before "cerven").
|
||||
names := make([]string, 0, len(czechMonths))
|
||||
for name := range czechMonths {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
if len(names[i]) != len(names[j]) {
|
||||
return len(names[i]) > len(names[j])
|
||||
}
|
||||
return names[i] < names[j]
|
||||
})
|
||||
alt := strings.Join(names, "|")
|
||||
|
||||
numericRe = regexp.MustCompile(`([\d+]+)\s*/\s*(\d{2,4})`)
|
||||
dotRe = regexp.MustCompile(`(\d{1,2})\s*\.\s*(\d{4})`)
|
||||
rangeRe = regexp.MustCompile(`(` + alt + `)\s*-\s*(` + alt + `)`)
|
||||
standRe = regexp.MustCompile(`\b(` + alt + `)\b`)
|
||||
}
|
||||
|
||||
// ParseMonthReferences extracts YYYY-MM month references from Czech free text.
|
||||
//
|
||||
// defaultYear seeds two heuristics: standalone month names with m >= 10 are
|
||||
// treated as defaultYear-1 (out-of-year backfill), and wrap-around ranges
|
||||
// (e.g. listopad-leden) place months >= start_m in defaultYear-1.
|
||||
//
|
||||
// Returns a sorted, deduplicated slice of "YYYY-MM" strings.
|
||||
func ParseMonthReferences(text string, defaultYear int) []string {
|
||||
normalized := Normalize(text)
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
add := func(year, m int) {
|
||||
if m >= 1 && m <= 12 {
|
||||
seen[fmt.Sprintf("%04d-%02d", year, m)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1: numeric months — "11+12/2025", "01/26", "1/2026"
|
||||
for _, groups := range numericRe.FindAllStringSubmatch(normalized, -1) {
|
||||
monthsPart, yearStr := groups[1], groups[2]
|
||||
year, err := strconv.Atoi(yearStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if year < 100 {
|
||||
year += 2000
|
||||
}
|
||||
for mStr := range strings.SplitSeq(monthsPart, "+") {
|
||||
mStr = strings.TrimSpace(mStr)
|
||||
if mStr == "" {
|
||||
continue
|
||||
}
|
||||
allDigits := true
|
||||
for _, c := range mStr {
|
||||
if c < '0' || c > '9' {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allDigits {
|
||||
continue
|
||||
}
|
||||
m, err := strconv.Atoi(mStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
add(year, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: dot-separated month.year — "12.2025" (4-digit year only)
|
||||
for _, groups := range dotRe.FindAllStringSubmatch(normalized, -1) {
|
||||
m, _ := strconv.Atoi(groups[1])
|
||||
year, _ := strconv.Atoi(groups[2])
|
||||
add(year, m)
|
||||
}
|
||||
|
||||
// Pass 3a: Czech month name ranges — "listopad-leden"
|
||||
foundInRanges := map[string]struct{}{}
|
||||
for _, groups := range rangeRe.FindAllStringSubmatch(normalized, -1) {
|
||||
startName, endName := groups[1], groups[2]
|
||||
foundInRanges[startName] = struct{}{}
|
||||
foundInRanges[endName] = struct{}{}
|
||||
startM := czechMonths[startName]
|
||||
endM := czechMonths[endName]
|
||||
wraps := startM > endM
|
||||
m := startM
|
||||
for range 12 {
|
||||
year := defaultYear
|
||||
if wraps && m >= startM {
|
||||
year = defaultYear - 1
|
||||
}
|
||||
add(year, m)
|
||||
if m == endM {
|
||||
break
|
||||
}
|
||||
m = m%12 + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3b: standalone Czech month names (not part of a range)
|
||||
for _, groups := range standRe.FindAllStringSubmatch(normalized, -1) {
|
||||
name := groups[1]
|
||||
if _, inRange := foundInRanges[name]; inRange {
|
||||
continue
|
||||
}
|
||||
m := czechMonths[name]
|
||||
year := defaultYear
|
||||
if m >= 10 {
|
||||
year = defaultYear - 1
|
||||
}
|
||||
add(year, m)
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
result = append(result, k)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
244
go/internal/domain/czech/parse_month_references_test.go
Normal file
244
go/internal/domain/czech/parse_month_references_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package czech
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMonthReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// All expected outputs verified against live Python implementation on 2026-05-05:
|
||||
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
defaultYear int
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
defaultYear: 2026,
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "numeric plus-split two months full year",
|
||||
input: "11+12/2025",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-11", "2025-12"},
|
||||
},
|
||||
{
|
||||
name: "numeric single month full year",
|
||||
input: "1/2026",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-01"},
|
||||
},
|
||||
{
|
||||
name: "numeric 2-digit year",
|
||||
input: "01/26",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-01"},
|
||||
},
|
||||
{
|
||||
name: "numeric plus-split with 2-digit year",
|
||||
input: "11+12/25",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-11", "2025-12"},
|
||||
},
|
||||
{
|
||||
name: "numeric three months sorted",
|
||||
input: "12+1+2/2026",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-01", "2026-02", "2026-12"},
|
||||
},
|
||||
{
|
||||
name: "dot pattern",
|
||||
input: "12.2025",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-12"},
|
||||
},
|
||||
{
|
||||
name: "dot pattern requires 4-digit year",
|
||||
input: "1.26",
|
||||
defaultYear: 2026,
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "standalone month below m10 threshold",
|
||||
input: "leden",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-01"},
|
||||
},
|
||||
{
|
||||
name: "standalone month m10 heuristic",
|
||||
input: "prosinec",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-12"},
|
||||
},
|
||||
{
|
||||
name: "declension prosince",
|
||||
input: "prosince",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-12"},
|
||||
},
|
||||
{
|
||||
name: "declension lednu",
|
||||
input: "lednu",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-01"},
|
||||
},
|
||||
{
|
||||
name: "standalone m10 boundary (rijen = October)",
|
||||
input: "rijen",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-10"},
|
||||
},
|
||||
{
|
||||
name: "standalone m9 just below boundary (zari = September)",
|
||||
input: "zari",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-09"},
|
||||
},
|
||||
{
|
||||
name: "range wrap Nov-Jan",
|
||||
input: "listopad-leden",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-11", "2025-12", "2026-01"},
|
||||
},
|
||||
{
|
||||
name: "range wrap starting at October",
|
||||
input: "rijen-leden",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-10", "2025-11", "2025-12", "2026-01"},
|
||||
},
|
||||
{
|
||||
name: "range no wrap",
|
||||
input: "unor-kveten",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-02", "2026-03", "2026-04", "2026-05"},
|
||||
},
|
||||
{
|
||||
name: "degenerate range same month",
|
||||
input: "leden-leden",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-01"},
|
||||
},
|
||||
{
|
||||
name: "range spanning m10 — heuristic does NOT fire for range members",
|
||||
input: "unor-listopad",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09", "2026-10", "2026-11"},
|
||||
},
|
||||
{
|
||||
name: "longest-match alternation cervenec beats cerven",
|
||||
input: "cervenec-srpen",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-07", "2026-08"},
|
||||
},
|
||||
{
|
||||
name: "range plus standalone — range excludes, dedup",
|
||||
input: "listopad-leden, prosinec",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-11", "2025-12", "2026-01"},
|
||||
},
|
||||
{
|
||||
name: "two standalones no range",
|
||||
input: "prosinec leden",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-12", "2026-01"},
|
||||
},
|
||||
{
|
||||
name: "numeric plus range mix",
|
||||
input: "11+12/2025, leden-brezen",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-11", "2025-12", "2026-01", "2026-02", "2026-03"},
|
||||
},
|
||||
{
|
||||
name: "dedup across numeric and standalone passes",
|
||||
input: "11+12/25 a listopad",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-11", "2025-12"},
|
||||
},
|
||||
{
|
||||
name: "no digits before slash — standalone fires instead",
|
||||
input: "prosince/2025",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-12"},
|
||||
},
|
||||
{
|
||||
name: "range with trailing slash-year — numeric fails, range wins",
|
||||
input: "listopad-prosinec/2025",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-11", "2026-12"},
|
||||
},
|
||||
{
|
||||
name: "dot pattern only — numeric matches but month out of 1-12 range",
|
||||
input: "01.2026 / 02.2026",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-01", "2026-02"},
|
||||
},
|
||||
{
|
||||
name: "leading slash — numeric matches at second slash",
|
||||
input: "/12/2025",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-12"},
|
||||
},
|
||||
{
|
||||
name: "uppercase input normalized",
|
||||
input: "PROSINEC",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-12"},
|
||||
},
|
||||
{
|
||||
name: "diacritics stripped by Normalize",
|
||||
input: "Žluťoučký prosinec",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-12"},
|
||||
},
|
||||
{
|
||||
name: "diacritics in range with spaces around dash",
|
||||
input: "Únor - květen",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-02", "2026-03", "2026-04", "2026-05"},
|
||||
},
|
||||
{
|
||||
name: "natural language mixed with numeric and standalone",
|
||||
input: "platba 11/2025 a leden",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-11", "2026-01"},
|
||||
},
|
||||
{
|
||||
name: "English month name not recognized",
|
||||
input: "December",
|
||||
defaultYear: 2026,
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "duplicate input deduped",
|
||||
input: "11+12/2025 11+12/2025",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2025-11", "2025-12"},
|
||||
},
|
||||
{
|
||||
name: "trailing year without separator ignored",
|
||||
input: "leden 2026",
|
||||
defaultYear: 2026,
|
||||
want: []string{"2026-01"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ParseMonthReferences(tc.input, tc.defaultYear)
|
||||
if got == nil {
|
||||
got = []string{}
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("ParseMonthReferences(%q, %d)\n got %v\n want %v",
|
||||
tc.input, tc.defaultYear, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
34
go/internal/domain/fees/fees.go
Normal file
34
go/internal/domain/fees/fees.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Package fees ports fee calculation from scripts/attendance.py.
|
||||
package fees
|
||||
|
||||
const (
|
||||
AdultFeeDefault = 700 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
|
||||
AdultFeeSingle = 200 // CZK for exactly 1 practice
|
||||
)
|
||||
|
||||
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py.
|
||||
// Months absent from this map fall back to AdultFeeDefault.
|
||||
var AdultFeeMonthlyRate = map[string]int{
|
||||
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
||||
"2026-01": 750, "2026-02": 750, "2026-03": 350,
|
||||
"2026-04": 700, "2026-05": 700,
|
||||
}
|
||||
|
||||
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
|
||||
// the given monthKey (format "YYYY-MM").
|
||||
//
|
||||
// 0 practices → 0
|
||||
// 1 practice → AdultFeeSingle (200)
|
||||
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
|
||||
func CalculateFee(attendanceCount int, monthKey string) int {
|
||||
if attendanceCount == 0 {
|
||||
return 0
|
||||
}
|
||||
if attendanceCount == 1 {
|
||||
return AdultFeeSingle
|
||||
}
|
||||
if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
|
||||
return rate
|
||||
}
|
||||
return AdultFeeDefault
|
||||
}
|
||||
37
go/internal/domain/fees/fees_test.go
Normal file
37
go/internal/domain/fees/fees_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package fees
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCalculateFee(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
||||
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
month string
|
||||
want int
|
||||
}{
|
||||
{"zero short-circuits", 0, "2026-05", 0},
|
||||
{"zero empty month", 0, "", 0},
|
||||
{"single practice", 1, "2026-05", 200},
|
||||
{"single ignores monthKey", 1, "unknown", 200},
|
||||
{"two practices configured month", 2, "2026-05", 700},
|
||||
{"two practices reduced march", 2, "2026-03", 350},
|
||||
{"two practices early season", 2, "2025-09", 750},
|
||||
{"high count same as two", 5, "2026-05", 700},
|
||||
{"unknown future month falls back", 2, "2027-01", 700},
|
||||
{"empty month falls back", 2, "", 700},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := CalculateFee(tc.count, tc.month)
|
||||
if got != tc.want {
|
||||
t.Errorf("CalculateFee(%d, %q) = %d, want %d", tc.count, tc.month, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
37
go/internal/domain/fees/junior.go
Normal file
37
go/internal/domain/fees/junior.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package fees
|
||||
|
||||
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
|
||||
|
||||
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py.
|
||||
// Months absent from this map fall back to JuniorFeeDefault.
|
||||
var JuniorFeeMonthlyRate = map[string]int{
|
||||
"2025-09": 250,
|
||||
"2026-03": 250,
|
||||
}
|
||||
|
||||
// Expected is the result of a junior fee calculation.
|
||||
// When Unknown is true the fee requires manual review (Python returns "?");
|
||||
// in that case Value is meaningless — always check Unknown first.
|
||||
type Expected struct {
|
||||
Value int
|
||||
Unknown bool
|
||||
}
|
||||
|
||||
// CalculateJuniorFee returns the junior fee for attendanceCount practices in
|
||||
// the given monthKey (format "YYYY-MM").
|
||||
//
|
||||
// 0 practices → Expected{Value: 0}
|
||||
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?")
|
||||
// 2+ → Expected{Value: JuniorFeeMonthlyRate[monthKey] or JuniorFeeDefault}
|
||||
func CalculateJuniorFee(attendanceCount int, monthKey string) Expected {
|
||||
if attendanceCount == 0 {
|
||||
return Expected{Value: 0}
|
||||
}
|
||||
if attendanceCount == 1 {
|
||||
return Expected{Unknown: true}
|
||||
}
|
||||
if rate, ok := JuniorFeeMonthlyRate[monthKey]; ok {
|
||||
return Expected{Value: rate}
|
||||
}
|
||||
return Expected{Value: JuniorFeeDefault}
|
||||
}
|
||||
37
go/internal/domain/fees/junior_test.go
Normal file
37
go/internal/domain/fees/junior_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package fees
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCalculateJuniorFee(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
||||
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
|
||||
tests := []struct {
|
||||
name string
|
||||
count int
|
||||
month string
|
||||
want Expected
|
||||
}{
|
||||
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
|
||||
{"zero empty month", 0, "", Expected{Value: 0}},
|
||||
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
|
||||
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
|
||||
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
|
||||
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
|
||||
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
|
||||
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
|
||||
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
|
||||
{"empty month falls back", 2, "", Expected{Value: 500}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := CalculateJuniorFee(tc.count, tc.month)
|
||||
if got != tc.want {
|
||||
t.Errorf("CalculateJuniorFee(%d, %q) = %+v, want %+v", tc.count, tc.month, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
49
go/internal/domain/money/money.go
Normal file
49
go/internal/domain/money/money.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py.
|
||||
package money
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidAmount is returned by ParseCZK when the input cannot be parsed.
|
||||
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
|
||||
|
||||
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
|
||||
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount:
|
||||
//
|
||||
// - empty input → (0, nil)
|
||||
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
|
||||
// - comma present → comma is decimal sep, dots/spaces are thousand seps
|
||||
// ("1.500,00" → 1500.0)
|
||||
// - no comma, 2+ dots → all dots are thousand seps ("1.500.000" → 1500000.0)
|
||||
// - no comma, ≤1 dot → dot is decimal sep ("1.500" → 1.5)
|
||||
// - on parse failure → (0, ErrInvalidAmount); callers wanting Python's
|
||||
// silent-zero behaviour can discard the error: v, _ := ParseCZK(s)
|
||||
func ParseCZK(s string) (float64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
s = strings.ReplaceAll(s, "Kč", "")
|
||||
s = strings.ReplaceAll(s, "CZK", "")
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
if strings.ContainsRune(s, ',') {
|
||||
s = strings.ReplaceAll(s, ".", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, ",", ".")
|
||||
} else if strings.Count(s, ".") > 1 {
|
||||
s = strings.ReplaceAll(s, ".", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
}
|
||||
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, ErrInvalidAmount
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
67
go/internal/domain/money/money_test.go
Normal file
67
go/internal/domain/money/money_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package money
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCZK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
||||
// PYTHONPATH=scripts:. python -c '
|
||||
// from infer_payments import parse_czk_amount
|
||||
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
|
||||
// "1 500", "1500.00", "1 500.00",
|
||||
// "1.500,00", "1500,5", "1.500.000",
|
||||
// "1.500", "abc", " ", "100,5 Kč"]:
|
||||
// print(repr(v), "->", parse_czk_amount(v))
|
||||
// '
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty string", "", 0, false},
|
||||
{"zero string", "0", 0, false},
|
||||
{"plain integer", "500", 500, false},
|
||||
{"with Kč suffix", "500 Kč", 500, false},
|
||||
{"with CZK suffix", "500 CZK", 500, false},
|
||||
{"space thousand sep", "1 500", 1500, false},
|
||||
{"dot decimal", "1500.00", 1500, false},
|
||||
{"space thousands dot decimal", "1 500.00", 1500, false},
|
||||
{"dot thousand comma decimal", "1.500,00", 1500, false},
|
||||
{"comma decimal no thousands", "1500,5", 1500.5, false},
|
||||
{"multiple dot thousand seps", "1.500.000", 1500000, false},
|
||||
{"single dot is decimal heuristic", "1.500", 1.5, false},
|
||||
{"comma decimal with Kč", "100,5 Kč", 100.5, false},
|
||||
{"garbage text", "abc", 0, true},
|
||||
{"spaces only", " ", 0, true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ParseCZK(tc.input)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("ParseCZK(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseCZK(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCZKSilentZero documents that discarding the error recovers Python's
|
||||
// silent-zero behaviour for any garbage input.
|
||||
func TestParseCZKSilentZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, s := range []string{"abc", " ", "Kč", "CZK"} {
|
||||
v, _ := ParseCZK(s)
|
||||
if v != 0 {
|
||||
t.Errorf("ParseCZK(%q) silent-zero: got %v, want 0", s, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,15 @@ from czech_utils import normalize, parse_month_references
|
||||
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
||||
|
||||
|
||||
def canonical_member_key(name: str) -> str:
|
||||
"""Diacritic-, case-, and whitespace-insensitive key for member-name matching.
|
||||
|
||||
Used to resolve `Person`-column values from the payments sheet to canonical
|
||||
attendance-sheet names, tolerating cells like "Maria Maco" vs "Mária Maco".
|
||||
"""
|
||||
return re.sub(r"\s+", " ", normalize(name)).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name matching
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -309,6 +318,12 @@ def reconcile(
|
||||
member_tiers = {name: tier for name, tier, _ in members}
|
||||
member_fees = {name: fees for name, _, fees in members}
|
||||
|
||||
# Map canonical key → first attendance-sheet name with that key, so a
|
||||
# `Person` cell that drifts in diacritics/case/whitespace still resolves.
|
||||
canonical_by_key: dict[str, str] = {}
|
||||
for name in member_names:
|
||||
canonical_by_key.setdefault(canonical_member_key(name), name)
|
||||
|
||||
# Initialize ledger
|
||||
ledger: dict[str, dict[str, dict]] = {}
|
||||
other_ledger: dict[str, list] = {}
|
||||
@@ -386,8 +401,9 @@ def reconcile(
|
||||
if is_other:
|
||||
num_allocations = len(matched_members)
|
||||
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
||||
for member_name, confidence in matched_members:
|
||||
if member_name in other_ledger:
|
||||
for raw_member_name, confidence in matched_members:
|
||||
member_name = canonical_by_key.get(canonical_member_key(raw_member_name))
|
||||
if member_name is not None:
|
||||
other_ledger[member_name].append({
|
||||
"amount": per_allocation,
|
||||
"date": tx["date"],
|
||||
@@ -400,14 +416,20 @@ def reconcile(
|
||||
|
||||
member_share = amount / len(matched_members) if matched_members else 0
|
||||
|
||||
for member_name, confidence in matched_members:
|
||||
if member_name not in ledger:
|
||||
for raw_member_name, confidence in matched_members:
|
||||
member_name = canonical_by_key.get(canonical_member_key(raw_member_name))
|
||||
if member_name is None:
|
||||
logger.warning(
|
||||
"Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
|
||||
member_name, tx.get("date", "?"), tx.get("message", "?"),
|
||||
raw_member_name, tx.get("date", "?"), tx.get("message", "?"),
|
||||
)
|
||||
unmatched.append(tx)
|
||||
continue
|
||||
if member_name != raw_member_name:
|
||||
logger.info(
|
||||
"Person cell %r resolved to canonical member %r — consider fixing the sheet",
|
||||
raw_member_name, member_name,
|
||||
)
|
||||
|
||||
in_window = [(m, ledger[member_name][m]["expected"]) for m in matched_months if m in ledger[member_name]]
|
||||
out_of_window = [m for m in matched_months if m not in ledger[member_name]]
|
||||
|
||||
@@ -365,6 +365,19 @@
|
||||
border-bottom: 1px dashed #222;
|
||||
}
|
||||
|
||||
.raw-toggle {
|
||||
color: #333;
|
||||
font-size: 9px;
|
||||
text-transform: lowercase;
|
||||
margin-left: 8px;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.raw-toggle:hover {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.modal-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -680,6 +693,16 @@
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">
|
||||
Raw Payments
|
||||
<a href="#" id="rawPaymentsToggle" class="raw-toggle" onclick="toggleRawPayments(event)">[show]</a>
|
||||
</div>
|
||||
<div id="modalRawList" class="tx-list" style="display: none;">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -696,6 +719,7 @@
|
||||
const memberData = {{ member_data| safe }};
|
||||
const sortedMonths = {{ raw_months| tojson }};
|
||||
const monthLabels = {{ month_labels_json| safe }};
|
||||
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
|
||||
let currentMemberName = null;
|
||||
|
||||
function showMemberDetails(name) {
|
||||
@@ -828,9 +852,49 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Raw payments (debug) — hidden by default, reset toggle on each open
|
||||
const rawList = document.getElementById('modalRawList');
|
||||
const rawToggle = document.getElementById('rawPaymentsToggle');
|
||||
rawList.style.display = 'none';
|
||||
rawToggle.textContent = '[show]';
|
||||
rawList.innerHTML = '';
|
||||
const rawRows = rawPaymentsByPerson[name] || [];
|
||||
if (rawRows.length === 0) {
|
||||
rawList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>';
|
||||
} else {
|
||||
rawRows.forEach(tx => {
|
||||
const inferredNote = tx.inferred_amount && tx.inferred_amount !== '' && tx.inferred_amount != tx.amount
|
||||
? ` <span style="color:#888;">(inferred: ${tx.inferred_amount})</span>`
|
||||
: '';
|
||||
const manualNote = tx.manual_fix ? ' <span style="color:#ffaa00;">[manual fix]</span>' : '';
|
||||
const bankIdNote = tx.bank_id ? `<span style="color:#444;"> · bank_id: ${tx.bank_id}</span>` : '';
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tx-item';
|
||||
item.innerHTML = `
|
||||
<div class="tx-meta">${tx.date} | purpose: ${tx.purpose || '—'}${manualNote}</div>
|
||||
<div class="tx-main">
|
||||
<span class="tx-amount">${tx.amount} CZK${inferredNote}</span>
|
||||
<span class="tx-sender">${tx.sender || ''}</span>
|
||||
</div>
|
||||
<div class="tx-msg">${tx.message || ''}</div>
|
||||
<div class="tx-meta">${tx.person || ''}${bankIdNote}</div>
|
||||
`;
|
||||
rawList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('memberModal').classList.add('active');
|
||||
}
|
||||
|
||||
function toggleRawPayments(ev) {
|
||||
ev.preventDefault();
|
||||
const list = document.getElementById('modalRawList');
|
||||
const link = document.getElementById('rawPaymentsToggle');
|
||||
const hidden = list.style.display === 'none';
|
||||
list.style.display = hidden ? 'block' : 'none';
|
||||
link.textContent = hidden ? '[hide]' : '[show]';
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
if (id) {
|
||||
document.getElementById(id).style.display = 'none';
|
||||
|
||||
@@ -365,6 +365,19 @@
|
||||
border-bottom: 1px dashed #222;
|
||||
}
|
||||
|
||||
.raw-toggle {
|
||||
color: #333;
|
||||
font-size: 9px;
|
||||
text-transform: lowercase;
|
||||
margin-left: 8px;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.raw-toggle:hover {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.modal-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -661,6 +674,16 @@
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">
|
||||
Raw Payments
|
||||
<a href="#" id="rawPaymentsToggle" class="raw-toggle" onclick="toggleRawPayments(event)">[show]</a>
|
||||
</div>
|
||||
<div id="modalRawList" class="tx-list" style="display: none;">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -677,6 +700,7 @@
|
||||
const memberData = {{ member_data| safe }};
|
||||
const sortedMonths = {{ raw_months| tojson }};
|
||||
const monthLabels = {{ month_labels_json| safe }};
|
||||
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
|
||||
let currentMemberName = null;
|
||||
|
||||
function showMemberDetails(name) {
|
||||
@@ -809,9 +833,49 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Raw payments (debug) — hidden by default, reset toggle on each open
|
||||
const rawList = document.getElementById('modalRawList');
|
||||
const rawToggle = document.getElementById('rawPaymentsToggle');
|
||||
rawList.style.display = 'none';
|
||||
rawToggle.textContent = '[show]';
|
||||
rawList.innerHTML = '';
|
||||
const rawRows = rawPaymentsByPerson[name] || [];
|
||||
if (rawRows.length === 0) {
|
||||
rawList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>';
|
||||
} else {
|
||||
rawRows.forEach(tx => {
|
||||
const inferredNote = tx.inferred_amount && tx.inferred_amount !== '' && tx.inferred_amount != tx.amount
|
||||
? ` <span style="color:#888;">(inferred: ${tx.inferred_amount})</span>`
|
||||
: '';
|
||||
const manualNote = tx.manual_fix ? ' <span style="color:#ffaa00;">[manual fix]</span>' : '';
|
||||
const bankIdNote = tx.bank_id ? `<span style="color:#444;"> · bank_id: ${tx.bank_id}</span>` : '';
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tx-item';
|
||||
item.innerHTML = `
|
||||
<div class="tx-meta">${tx.date} | purpose: ${tx.purpose || '—'}${manualNote}</div>
|
||||
<div class="tx-main">
|
||||
<span class="tx-amount">${tx.amount} CZK${inferredNote}</span>
|
||||
<span class="tx-sender">${tx.sender || ''}</span>
|
||||
</div>
|
||||
<div class="tx-msg">${tx.message || ''}</div>
|
||||
<div class="tx-meta">${tx.person || ''}${bankIdNote}</div>
|
||||
`;
|
||||
rawList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('memberModal').classList.add('active');
|
||||
}
|
||||
|
||||
function toggleRawPayments(ev) {
|
||||
ev.preventDefault();
|
||||
const list = document.getElementById('modalRawList');
|
||||
const link = document.getElementById('rawPaymentsToggle');
|
||||
const hidden = list.style.display === 'none';
|
||||
list.style.display = hidden ? 'block' : 'none';
|
||||
link.textContent = hidden ? '[hide]' : '[show]';
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
if (id) {
|
||||
document.getElementById(id).style.display = 'none';
|
||||
|
||||
@@ -19,67 +19,6 @@ class TestWebApp(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'url=/adults', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.get_members_with_fees')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
def test_fees_route(self, mock_exceptions, mock_get_members, mock_cache):
|
||||
"""Test that /fees returns 200 and renders the dashboard"""
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
|
||||
response = self.client.get('/fees')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'FUJ Fees Dashboard', response.data)
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.get_junior_members_with_fees')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
def test_fees_juniors_route(self, mock_exceptions, mock_get_junior_members, mock_cache):
|
||||
"""Test that /fees-juniors returns 200 and renders the junior dashboard"""
|
||||
mock_get_junior_members.return_value = (
|
||||
[
|
||||
('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}),
|
||||
('Test Junior 2', 'J', {'2026-01': (500, 4, 1, 3)})
|
||||
],
|
||||
['2026-01']
|
||||
)
|
||||
|
||||
response = self.client.get('/fees-juniors')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'FUJ Junior Fees Dashboard', response.data)
|
||||
self.assertIn(b'Test Junior 1', response.data)
|
||||
self.assertIn(b'? / 1 (J)', response.data)
|
||||
self.assertIn(b'500 CZK / 4 (1A+3J)', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
@patch('app.get_members_with_fees')
|
||||
def test_reconcile_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||
"""Test that /reconcile returns 200 and shows matches"""
|
||||
mock_get_members.return_value = (
|
||||
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||
['2026-01']
|
||||
)
|
||||
mock_fetch_sheet.return_value = [{
|
||||
'date': '2026-01-01',
|
||||
'amount': 750,
|
||||
'person': 'Test Member',
|
||||
'purpose': '2026-01',
|
||||
'message': 'test payment',
|
||||
'sender': 'External Bank User',
|
||||
'inferred_amount': 750
|
||||
}]
|
||||
|
||||
response = self.client.get('/reconcile')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Payment Reconciliation', response.data)
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
self.assertIn(b'OK', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
def test_payments_route(self, mock_fetch_sheet, mock_cache):
|
||||
@@ -98,38 +37,6 @@ class TestWebApp(unittest.TestCase):
|
||||
self.assertIn(b'Test Member', response.data)
|
||||
self.assertIn(b'Direct Member Payment', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions')
|
||||
@patch('app.get_junior_members_with_fees')
|
||||
def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions, mock_cache):
|
||||
"""Test that /reconcile-juniors correctly computes balances for juniors."""
|
||||
mock_get_junior.return_value = (
|
||||
[
|
||||
('Junior One', 'J', {'2026-01': (500, 4, 2, 2)}),
|
||||
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
|
||||
],
|
||||
['2026-01']
|
||||
)
|
||||
mock_exceptions.return_value = {}
|
||||
mock_transactions.return_value = [{
|
||||
'date': '2026-01-15',
|
||||
'amount': 500,
|
||||
'person': 'Junior One',
|
||||
'purpose': '2026-01',
|
||||
'message': '',
|
||||
'sender': 'Parent',
|
||||
'inferred_amount': 500
|
||||
}]
|
||||
|
||||
response = self.client.get('/reconcile-juniors')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Junior Payment Reconciliation', response.data)
|
||||
self.assertIn(b'Junior One', response.data)
|
||||
self.assertIn(b'Junior Two', response.data)
|
||||
self.assertIn(b'OK', response.data)
|
||||
self.assertIn(b'?', response.data)
|
||||
|
||||
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||
@patch('app.fetch_sheet_data')
|
||||
@patch('app.fetch_exceptions', return_value={})
|
||||
|
||||
69
tests/test_match_payments.py
Normal file
69
tests/test_match_payments.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import unittest
|
||||
|
||||
from scripts.match_payments import canonical_member_key, reconcile
|
||||
|
||||
|
||||
class TestCanonicalMemberKey(unittest.TestCase):
|
||||
def test_diacritics_and_case_collapse(self):
|
||||
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
|
||||
self.assertEqual(canonical_member_key("MARIA MACO"), "maria maco")
|
||||
self.assertEqual(canonical_member_key("maria maco"), "maria maco")
|
||||
|
||||
def test_whitespace_runs_collapse(self):
|
||||
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
|
||||
self.assertEqual(canonical_member_key(" Mária Maco "), "maria maco")
|
||||
|
||||
def test_unknown_name_passes_through_normalized(self):
|
||||
# Two genuinely different names must not collide.
|
||||
self.assertNotEqual(
|
||||
canonical_member_key("Mária Maco"),
|
||||
canonical_member_key("Marek Maco"),
|
||||
)
|
||||
|
||||
|
||||
class TestReconcileTolerantPersonMatching(unittest.TestCase):
|
||||
def _members(self):
|
||||
return [("Mária Maco", "A", {"2026-04": (750, 4)})]
|
||||
|
||||
def _tx(self, person):
|
||||
return {
|
||||
"date": "2026-04-15",
|
||||
"amount": 750,
|
||||
"person": person,
|
||||
"purpose": "2026-04",
|
||||
"inferred_amount": 750,
|
||||
"sender": "Maco Family",
|
||||
"message": "fee",
|
||||
}
|
||||
|
||||
def test_person_without_diacritics_matches(self):
|
||||
result = reconcile(self._members(), ["2026-04"], [self._tx("Maria Maco")], {})
|
||||
|
||||
member = result["members"]["Mária Maco"]
|
||||
self.assertEqual(member["months"]["2026-04"]["paid"], 750)
|
||||
self.assertEqual(len(member["months"]["2026-04"]["transactions"]), 1)
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
def test_person_with_extra_whitespace_matches(self):
|
||||
result = reconcile(self._members(), ["2026-04"], [self._tx("Mária Maco")], {})
|
||||
|
||||
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
def test_person_lowercase_matches(self):
|
||||
result = reconcile(self._members(), ["2026-04"], [self._tx("mária maco")], {})
|
||||
|
||||
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
|
||||
self.assertEqual(result["unmatched"], [])
|
||||
|
||||
def test_truly_unknown_person_still_unmatched(self):
|
||||
result = reconcile(
|
||||
self._members(), ["2026-04"], [self._tx("Někdo Neznámý")], {}
|
||||
)
|
||||
|
||||
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 0)
|
||||
self.assertEqual(len(result["unmatched"]), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user