Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 394da2e6b8 |
58
app.py
58
app.py
@@ -22,7 +22,7 @@ from config import (
|
|||||||
BANK_ACCOUNT, CREDENTIALS_PATH,
|
BANK_ACCOUNT, CREDENTIALS_PATH,
|
||||||
)
|
)
|
||||||
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
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 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 sync_fio_to_sheets import sync_to_sheets
|
||||||
from infer_payments import infer_payments
|
from infer_payments import infer_payments
|
||||||
@@ -57,6 +57,25 @@ def get_month_labels(sorted_months, merged_months):
|
|||||||
labels[m] = dt.strftime("%b %Y")
|
labels[m] = dt.strftime("%b %Y")
|
||||||
return labels
|
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():
|
def warmup_cache():
|
||||||
"""Pre-fetch all cached data so first request is fast."""
|
"""Pre-fetch all cached data so first request is fast."""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -304,6 +323,7 @@ def adults_view():
|
|||||||
unmatched = result["unmatched"]
|
unmatched = result["unmatched"]
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in members])
|
||||||
record_step("process_data")
|
record_step("process_data")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -314,6 +334,7 @@ def adults_view():
|
|||||||
totals=formatted_totals,
|
totals=formatted_totals,
|
||||||
member_data=json.dumps(result["members"]),
|
member_data=json.dumps(result["members"]),
|
||||||
month_labels_json=json.dumps(month_labels),
|
month_labels_json=json.dumps(month_labels),
|
||||||
|
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||||
credits=credits,
|
credits=credits,
|
||||||
debts=debts,
|
debts=debts,
|
||||||
unmatched=unmatched,
|
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"])
|
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"])
|
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"]
|
unmatched = result["unmatched"]
|
||||||
|
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in adapted_members])
|
||||||
import json
|
import json
|
||||||
|
|
||||||
record_step("process_data")
|
record_step("process_data")
|
||||||
@@ -518,6 +540,7 @@ def juniors_view():
|
|||||||
totals=formatted_totals,
|
totals=formatted_totals,
|
||||||
member_data=json.dumps(result["members"]),
|
member_data=json.dumps(result["members"]),
|
||||||
month_labels_json=json.dumps(month_labels),
|
month_labels_json=json.dumps(month_labels),
|
||||||
|
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||||
credits=credits,
|
credits=credits,
|
||||||
debts=debts,
|
debts=debts,
|
||||||
unmatched=unmatched,
|
unmatched=unmatched,
|
||||||
@@ -536,27 +559,22 @@ def payments():
|
|||||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||||
record_step("fetch_payments")
|
record_step("fetch_payments")
|
||||||
|
|
||||||
# Group transactions by person
|
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
grouped = {}
|
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:
|
for tx in transactions:
|
||||||
person = str(tx.get("person", "")).strip()
|
if not str(tx.get("person", "")).strip():
|
||||||
if not person:
|
grouped.setdefault("Unmatched / Unknown", []).append(tx)
|
||||||
person = "Unmatched / Unknown"
|
for rows in grouped.values():
|
||||||
|
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||||
# 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
|
|
||||||
sorted_people = sorted(grouped.keys())
|
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")
|
record_step("process_data")
|
||||||
return render_template(
|
return render_template(
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -17,6 +17,15 @@ from czech_utils import normalize, parse_month_references
|
|||||||
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
|
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
|
# Name matching
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -309,6 +318,12 @@ def reconcile(
|
|||||||
member_tiers = {name: tier for name, tier, _ in members}
|
member_tiers = {name: tier for name, tier, _ in members}
|
||||||
member_fees = {name: fees for name, _, fees 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
|
# Initialize ledger
|
||||||
ledger: dict[str, dict[str, dict]] = {}
|
ledger: dict[str, dict[str, dict]] = {}
|
||||||
other_ledger: dict[str, list] = {}
|
other_ledger: dict[str, list] = {}
|
||||||
@@ -386,8 +401,9 @@ def reconcile(
|
|||||||
if is_other:
|
if is_other:
|
||||||
num_allocations = len(matched_members)
|
num_allocations = len(matched_members)
|
||||||
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
per_allocation = amount / num_allocations if num_allocations > 0 else 0
|
||||||
for member_name, confidence in matched_members:
|
for raw_member_name, confidence in matched_members:
|
||||||
if member_name in other_ledger:
|
member_name = canonical_by_key.get(canonical_member_key(raw_member_name))
|
||||||
|
if member_name is not None:
|
||||||
other_ledger[member_name].append({
|
other_ledger[member_name].append({
|
||||||
"amount": per_allocation,
|
"amount": per_allocation,
|
||||||
"date": tx["date"],
|
"date": tx["date"],
|
||||||
@@ -400,14 +416,20 @@ def reconcile(
|
|||||||
|
|
||||||
member_share = amount / len(matched_members) if matched_members else 0
|
member_share = amount / len(matched_members) if matched_members else 0
|
||||||
|
|
||||||
for member_name, confidence in matched_members:
|
for raw_member_name, confidence in matched_members:
|
||||||
if member_name not in ledger:
|
member_name = canonical_by_key.get(canonical_member_key(raw_member_name))
|
||||||
|
if member_name is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
|
"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)
|
unmatched.append(tx)
|
||||||
continue
|
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]]
|
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]]
|
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;
|
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 {
|
.modal-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -680,6 +693,16 @@
|
|||||||
<!-- Filled by JS -->
|
<!-- Filled by JS -->
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -696,6 +719,7 @@
|
|||||||
const memberData = {{ member_data| safe }};
|
const memberData = {{ member_data| safe }};
|
||||||
const sortedMonths = {{ raw_months| tojson }};
|
const sortedMonths = {{ raw_months| tojson }};
|
||||||
const monthLabels = {{ month_labels_json| safe }};
|
const monthLabels = {{ month_labels_json| safe }};
|
||||||
|
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
|
||||||
let currentMemberName = null;
|
let currentMemberName = null;
|
||||||
|
|
||||||
function showMemberDetails(name) {
|
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');
|
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) {
|
function closeModal(id) {
|
||||||
if (id) {
|
if (id) {
|
||||||
document.getElementById(id).style.display = 'none';
|
document.getElementById(id).style.display = 'none';
|
||||||
|
|||||||
@@ -365,6 +365,19 @@
|
|||||||
border-bottom: 1px dashed #222;
|
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 {
|
.modal-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -661,6 +674,16 @@
|
|||||||
<!-- Filled by JS -->
|
<!-- Filled by JS -->
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -677,6 +700,7 @@
|
|||||||
const memberData = {{ member_data| safe }};
|
const memberData = {{ member_data| safe }};
|
||||||
const sortedMonths = {{ raw_months| tojson }};
|
const sortedMonths = {{ raw_months| tojson }};
|
||||||
const monthLabels = {{ month_labels_json| safe }};
|
const monthLabels = {{ month_labels_json| safe }};
|
||||||
|
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
|
||||||
let currentMemberName = null;
|
let currentMemberName = null;
|
||||||
|
|
||||||
function showMemberDetails(name) {
|
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');
|
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) {
|
function closeModal(id) {
|
||||||
if (id) {
|
if (id) {
|
||||||
document.getElementById(id).style.display = 'none';
|
document.getElementById(id).style.display = 'none';
|
||||||
|
|||||||
@@ -19,67 +19,6 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(b'url=/adults', response.data)
|
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.get_cached_data', side_effect=_bypass_cache)
|
||||||
@patch('app.fetch_sheet_data')
|
@patch('app.fetch_sheet_data')
|
||||||
def test_payments_route(self, mock_fetch_sheet, mock_cache):
|
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'Test Member', response.data)
|
||||||
self.assertIn(b'Direct Member Payment', 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.get_cached_data', side_effect=_bypass_cache)
|
||||||
@patch('app.fetch_sheet_data')
|
@patch('app.fetch_sheet_data')
|
||||||
@patch('app.fetch_exceptions', return_value={})
|
@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