Files
fuj-management/docs/plans/2026-05-05-1637-member-modal-raw-payments-debug.md
Jan Novak 394da2e6b8
Some checks failed
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 6s
Build and Push / build-go (push) Failing after 6s
fix: Tolerate diacritic/case/whitespace mismatches in Person column matching
- 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>
2026-05-05 17:22:54 +02:00

6.5 KiB

Member modal — raw payments debug list

Context

When a payer's bank message doesn't follow our convention, 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(). 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: 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:

  • Factor the existing per-person grouping in payments() into a small helper near the top of the file:

    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(), adults_view() and juniors_view() — 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 and 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:

    const rawPaymentsByPerson = {{ raw_payments_json| safe }};
    

    (next to adults.html:696).

  • Add a new section directly after the Payment History block:

    <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).
  • Add the toggle handler near closeModal:

    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). 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 — 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 — modal section + JS + tiny CSS for the toggle link.
  • 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.