- Add canonical_member_key() in match_payments.py to normalize names via NFKD + lowercase + whitespace-collapse before ledger lookup; resolves payments attributed to e.g. "Maria Maco" to canonical "Mária Maco". Emits logger.info when a non-canonical cell is rescued so sheet typos are visible in logs without losing the payment allocation. - Extend group_payments_by_person() in app.py to accept member_names and re-key raw-payment groups under the canonical attendance-sheet name so the modal's Raw Payments debug section also finds the row correctly. - Add raw payments collapsible section to member detail modal in adults.html and juniors.html for debugging payment attribution issues. - Remove 4 obsolete tests targeting routes /fees, /fees-juniors, /reconcile, /reconcile-juniors that no longer exist; add test_match_payments.py covering canonical key equivalence and reconcile() tolerance end-to-end. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
100 lines
6.5 KiB
Markdown
100 lines
6.5 KiB
Markdown
# 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.
|