Compare commits
7 Commits
feat/go-m6
...
0.35
| Author | SHA1 | Date | |
|---|---|---|---|
| 10e2e9dc04 | |||
| 8734089223 | |||
| aaa876e593 | |||
| f25552eef2 | |||
| 4276d7b915 | |||
| f6ba85b18f | |||
| 919845518c |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-11 23:58 CEST — fix(reconcile): fill earliest month deficit first in multi-month allocations
|
||||||
|
|
||||||
|
- Multi-month payment allocation now fills the earliest in-window deficit first and spills
|
||||||
|
any remainder to later months, accounting for prior transactions' contributions to each month.
|
||||||
|
Previously a single transaction was split proportionally to each month's total expected fee,
|
||||||
|
ignoring what earlier transactions had already paid — surfaced by Matyáš Thér's 200+550 case
|
||||||
|
showing 566/183 instead of 500/250.
|
||||||
|
- Files: `scripts/match_payments.py`, `go/internal/domain/reconcile/reconcile.go`, tests, parity fixtures.
|
||||||
|
|
||||||
|
## 2026-05-11 22:56 CEST — fix(python): parse Fio 2-digit-year dates + add `make sync-debug` dry-run tool
|
||||||
|
|
||||||
|
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
|
||||||
|
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
|
||||||
|
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
|
||||||
|
|
||||||
|
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal
|
||||||
|
|
||||||
|
- Restored the Python `showPayQR` in-page modal UX that was lost in M6.6 (Pay buttons were navigating the tab to the raw `/qr` PNG).
|
||||||
|
- Replaced `<a href="{{qrHref ...}}">Pay</a>` with `<button data-name|amount|month|raw-month>` on `/adults` and `/juniors`; click is handled by a new `static/js/payment-qr.js` IIFE module that opens `#qrModal` with title, account, amount, message, and the QR image.
|
||||||
|
- Added `#qrModal` markup to both templates; CSS `display:none` / `.active{display:flex}` rules added (content rules were already present from M6.1). `Esc`, `[close]`, and outside-click all dismiss; coexists with the M6.5 member-detail modal.
|
||||||
|
- Removed the now-dead `qrHref` / `qrHrefAll` template helpers from `render.go`.
|
||||||
|
- Markup tests in `html_handler_test.go` assert modal IDs, script tag, `data-bank-account`, and that no bare `href="/qr"` links remain.
|
||||||
|
- Key files: `go/internal/web/static/js/payment-qr.js`, `go/internal/web/templates/adults.tmpl`, `go/internal/web/templates/juniors.tmpl`, `go/internal/web/render.go`, `go/internal/web/static/css/app.css`.
|
||||||
|
|
||||||
## 2026-05-08 13:57 CEST — feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version
|
## 2026-05-08 13:57 CEST — feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version
|
||||||
|
|
||||||
- Added `GET /qr`: generates Czech QR Platba PNG from SPD payload (account, amount, message query params); ports Python's `qr_code()` handler exactly including account validation, amount clamping, and `*` stripping.
|
- Added `GET /qr`: generates Czech QR Platba PNG from SPD payload (account, amount, message query params); ports Python's `qr_code()` handler exactly including account validation, amount clamping, and `*` stripping.
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -35,6 +35,7 @@ help:
|
|||||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||||
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
||||||
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
||||||
|
@echo " make sync-debug [DAYS=N] - Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)"
|
||||||
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||||
@echo " make venv - Sync virtual environment with pyproject.toml"
|
@echo " make venv - Sync virtual environment with pyproject.toml"
|
||||||
@@ -125,6 +126,9 @@ sync-2025: $(PYTHON)
|
|||||||
sync-2026: $(PYTHON)
|
sync-2026: $(PYTHON)
|
||||||
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
||||||
|
|
||||||
|
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
|
||||||
|
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table
|
||||||
|
|
||||||
infer: $(PYTHON)
|
infer: $(PYTHON)
|
||||||
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-
|
|||||||
|
|
||||||
**Current milestone:** M6 — Go-native HTML frontend
|
**Current milestone:** M6 — Go-native HTML frontend
|
||||||
**Started:** 2026-05-04
|
**Started:** 2026-05-04
|
||||||
**Last updated:** 2026-05-08 (M6.4 merged)
|
**Last updated:** 2026-05-08 (M6.6 + M6.6.1 merged)
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
@@ -115,7 +115,8 @@ Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
|||||||
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
- [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
|
||||||
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
|
- [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
|
||||||
- [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238`
|
- [x] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) — `e53e238`
|
||||||
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
- [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b`
|
||||||
|
- [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b`
|
||||||
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
||||||
|
|
||||||
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
|
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
|
||||||
|
|||||||
335
docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md
Normal file
335
docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# M6.6.1 — Pay-button QR popup modal (`/adults`, `/juniors`)
|
||||||
|
|
||||||
|
> Plan-mode note: per project convention this plan should live at
|
||||||
|
> `docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md`. The first
|
||||||
|
> execution step after approval is to copy this file there.
|
||||||
|
> Branch: `feat/go-m6-6-1-payment-qr-modal` off `main`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M6.6 ([docs/plans/2026-05-08-1334-go-m6-6-action-pages.md](docs/plans/2026-05-08-1334-go-m6-6-action-pages.md))
|
||||||
|
landed the backend `/qr` PNG endpoint plus `/sync-bank`, `/flush-cache`,
|
||||||
|
`/version`. While doing it, the **client-side** half of the Pay flow was
|
||||||
|
collapsed from a modal-with-details into a plain `<a href="/qr?...">` —
|
||||||
|
clicking Pay now navigates the whole tab to the raw PNG.
|
||||||
|
|
||||||
|
The Python app instead opens an in-page `#qrModal` showing the QR image
|
||||||
|
*plus* labelled details (account, amount, message, title), then closes
|
||||||
|
the modal on outside-click or `[close]`
|
||||||
|
([templates/adults.html:631-648](templates/adults.html#L631-L648),
|
||||||
|
JS at [templates/adults.html:963-993](templates/adults.html#L963-L993)).
|
||||||
|
This is the UX the user expects from the dashboard — staying on the
|
||||||
|
table while displaying the QR is significantly more practical than
|
||||||
|
losing context to a fullscreen image.
|
||||||
|
|
||||||
|
M6.6.1 restores parity: the Pay/Pay-All buttons open an in-page
|
||||||
|
`#qrModal`, identical in structure to the Python original, driven by a
|
||||||
|
new vanilla-JS module `payment-qr.js` that mirrors the `member-detail.js`
|
||||||
|
convention from M6.5.
|
||||||
|
|
||||||
|
All the building blocks are already in place:
|
||||||
|
|
||||||
|
- **`/qr` endpoint** ships PNGs ([go/internal/web/qr.go](go/internal/web/qr.go), tested by [TestServeQR](go/internal/web/html_handler_test.go#L207-L231)).
|
||||||
|
- **CSS** for `#qrModal .modal-content`, `.qr-image`, `.qr-details` is
|
||||||
|
already in [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — lifted during M6.1.
|
||||||
|
- **Template helpers** `qrHref` / `qrHrefAll` ([go/internal/web/render.go:63-85](go/internal/web/render.go#L63-L85))
|
||||||
|
already produce the correct query string + Czech `MM/YYYY` format
|
||||||
|
for the `message=` param. They're useful for sanity (and for any
|
||||||
|
no-JS fallback) but become dead code post-M6.6.1 — see §Out of scope.
|
||||||
|
- **Bank account** is already exposed as `$.Data.BankAccount` to every
|
||||||
|
template that has a Pay button.
|
||||||
|
- **JS module convention**: `member-detail.js` is the M6.5 baseline —
|
||||||
|
IIFE, `'use strict'`, event delegation off `data-*` attributes, no
|
||||||
|
inline `onclick`, no globals leaked.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
### 1. Replace `<a href="{{qrHref ...}}">` with `<button data-...>`
|
||||||
|
|
||||||
|
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
|
||||||
|
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl)
|
||||||
|
(both have identical Pay markup at lines 65 and 72):
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<button type="button" class="pay-btn"
|
||||||
|
data-name="{{$row.Name}}"
|
||||||
|
data-amount="{{$cell.Amount}}"
|
||||||
|
data-month="{{$cell.Month}}"
|
||||||
|
data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<button type="button" class="pay-btn"
|
||||||
|
data-name="{{$row.Name}}"
|
||||||
|
data-amount="{{$row.PayableAmount}}"
|
||||||
|
data-month="{{$row.UnpaidPeriods}}"
|
||||||
|
data-raw-month="{{$row.RawUnpaidPeriods}}"
|
||||||
|
data-pay-all="1">Pay All</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Why `<button type="button">` and not `<a>`: the click no longer
|
||||||
|
navigates — the JS handles it. `type="button"` prevents accidental form
|
||||||
|
submission. A no-JS fallback could be added later by re-introducing
|
||||||
|
`href="{{qrHref ...}}"` and letting the JS call `e.preventDefault()` —
|
||||||
|
deferred until anyone actually asks (see §Out of scope).
|
||||||
|
|
||||||
|
`data-month` carries the human-readable period (e.g. `January 2026`,
|
||||||
|
or `January 2026 + February 2026` for Pay-All) which the modal title
|
||||||
|
displays. `data-raw-month` carries the machine format (`2026-01`,
|
||||||
|
`2026-01+2026-02`) which the JS converts to `MM/YYYY` for the QR
|
||||||
|
message — same conversion Python's `showPayQR` does at
|
||||||
|
[templates/adults.html:966-968](templates/adults.html#L966-L968).
|
||||||
|
`data-pay-all` is purely informational; the JS doesn't actually need
|
||||||
|
it (the same code path handles single + multi-period), but keeping it
|
||||||
|
helps debugging and any future analytics.
|
||||||
|
|
||||||
|
### 2. Add `#qrModal` markup
|
||||||
|
|
||||||
|
Append after the `#memberModal` block in both `adults.tmpl` and
|
||||||
|
`juniors.tmpl` (around line 185 of adults, line 165 of juniors), mirroring
|
||||||
|
[templates/adults.html:631-648](templates/adults.html#L631-L648) but
|
||||||
|
using `class="active"` toggling (the convention M6.5 picked) instead of
|
||||||
|
`style.display = 'block'`:
|
||||||
|
|
||||||
|
```gotmpl
|
||||||
|
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The CSS already targets `#qrModal .modal-content` directly. To make the
|
||||||
|
"hidden by default / visible when `.active`" toggle behave the same
|
||||||
|
way `#memberModal` does, the existing CSS rules for `#memberModal`
|
||||||
|
(`display:none`, `#memberModal.active { display: flex; }`) need a
|
||||||
|
sibling pair for `#qrModal`. Audit `app.css` during implementation:
|
||||||
|
- if `#qrModal` already has both rules, no change.
|
||||||
|
- if it has only `display:none`, add `#qrModal.active { display: flex; }`.
|
||||||
|
- if it has neither, add both.
|
||||||
|
|
||||||
|
This is a 2-line CSS adjustment at most; tracked under §Critical files.
|
||||||
|
|
||||||
|
### 3. Bank account on the page
|
||||||
|
|
||||||
|
The modal needs the bank account string for the `qrAccount` label and
|
||||||
|
for building the `/qr?account=…` URL. Two clean options:
|
||||||
|
|
||||||
|
**(a)** Read it from the `<button>`'s `data-bank-account` attribute.
|
||||||
|
Per-button duplication, but trivially done in templates and keeps the JS
|
||||||
|
module's surface tiny.
|
||||||
|
|
||||||
|
**(b)** Stamp it once on the page as `<body data-bank-account="…">`
|
||||||
|
(or on the existing `#filterContainer`).
|
||||||
|
|
||||||
|
**Decision: (b)** — single stamping site, follows the same pattern
|
||||||
|
M6.5 used for `data-page` on `#filterContainer`. Add
|
||||||
|
`data-bank-account="{{.Data.BankAccount}}"` to `#filterContainer` in
|
||||||
|
both templates (one-line edit each).
|
||||||
|
|
||||||
|
### 4. Create [go/internal/web/static/js/payment-qr.js](go/internal/web/static/js/payment-qr.js)
|
||||||
|
|
||||||
|
A new module loaded with `<script src="/static/js/payment-qr.js" defer></script>`
|
||||||
|
right after `member-detail.js` in both templates. Pure ES, no build
|
||||||
|
step, IIFE, `'use strict'`. Skeleton — port of Python's `showPayQR`
|
||||||
|
([templates/adults.html:963-986](templates/adults.html#L963-L986))
|
||||||
|
plus event wiring:
|
||||||
|
|
||||||
|
```js
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const bankAccount = container.dataset.bankAccount || '';
|
||||||
|
|
||||||
|
const modal = document.getElementById('qrModal');
|
||||||
|
const titleEl = document.getElementById('qrTitle');
|
||||||
|
const imgEl = document.getElementById('qrImg');
|
||||||
|
const accountEl = document.getElementById('qrAccount');
|
||||||
|
const amountEl = document.getElementById('qrAmount');
|
||||||
|
const messageEl = document.getElementById('qrMessage');
|
||||||
|
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
|
||||||
|
|
||||||
|
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges are converted piece-wise.
|
||||||
|
// Mirrors templates/adults.html:966-968.
|
||||||
|
function toCzechMonth(rawMonth) {
|
||||||
|
return rawMonth.split('+')
|
||||||
|
.map(p => p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'))
|
||||||
|
.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQR(name, amount, month, rawMonth) {
|
||||||
|
const numericMonth = toCzechMonth(rawMonth);
|
||||||
|
const message = `${name}: ${numericMonth}`;
|
||||||
|
|
||||||
|
titleEl.innerText = `Payment for ${month}`;
|
||||||
|
accountEl.innerText = bankAccount;
|
||||||
|
amountEl.innerText = amount;
|
||||||
|
messageEl.innerText = message;
|
||||||
|
|
||||||
|
const url = '/qr?'
|
||||||
|
+ 'account=' + encodeURIComponent(bankAccount)
|
||||||
|
+ '&amount=' + encodeURIComponent(amount)
|
||||||
|
+ '&message=' + encodeURIComponent(message);
|
||||||
|
imgEl.src = url;
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQR() { modal.classList.remove('active'); }
|
||||||
|
|
||||||
|
// Event delegation: any .pay-btn click anywhere in the page.
|
||||||
|
document.addEventListener('click', ev => {
|
||||||
|
const btn = ev.target.closest('.pay-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
showQR(
|
||||||
|
btn.dataset.name,
|
||||||
|
btn.dataset.amount,
|
||||||
|
btn.dataset.month,
|
||||||
|
btn.dataset.rawMonth,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Event delegation** (single listener on `document`) handles both Pay
|
||||||
|
and Pay-All without enumerating buttons. It also survives any future
|
||||||
|
re-render of the table without needing rebinding.
|
||||||
|
- **Coexistence with `member-detail.js`**: `member-detail.js` listens
|
||||||
|
for `Escape` too, but only acts when `#memberModal` is active; the
|
||||||
|
new module similarly only acts when `#qrModal` is active. No collision.
|
||||||
|
- **Pre-warming**: nothing to pre-warm — `/qr` is a tiny on-demand PNG
|
||||||
|
and only fires when the user clicks.
|
||||||
|
- **Inline `onclick`** on `[close]` and outside-click in the markup are
|
||||||
|
carried over from the M6.5 modal style; consistent with `#memberModal`.
|
||||||
|
(Switching the project's modal close pattern to data attributes is a
|
||||||
|
separate cleanup, out of scope here.)
|
||||||
|
|
||||||
|
### 5. Tests
|
||||||
|
|
||||||
|
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
|
||||||
|
with markup-level checks. Headless-browser JS is out of scope (same
|
||||||
|
rationale as M6.5).
|
||||||
|
|
||||||
|
In `TestModalMarkup` (or a sibling test, e.g. `TestPaymentQRMarkup`):
|
||||||
|
|
||||||
|
- Body for `/adults` and `/juniors` contains
|
||||||
|
`<script src="/static/js/payment-qr.js"`.
|
||||||
|
- Body contains `id="qrModal"`, `id="qrImg"`, `id="qrAccount"`,
|
||||||
|
`id="qrAmount"`, `id="qrMessage"`, `id="qrTitle"`.
|
||||||
|
- The fixture row's Pay button is rendered as
|
||||||
|
`<button … class="pay-btn" … data-name="Test Member" data-amount="…" data-raw-month="2026-01"`.
|
||||||
|
- The fixture row's `.pay-btn` does **not** include `href=`.
|
||||||
|
- `#filterContainer` has `data-bank-account="CZ0000000000000000000000"`
|
||||||
|
(matching the test's `fixtureHandler` config).
|
||||||
|
|
||||||
|
### 6. Decide fate of `qrHref` / `qrHrefAll` template helpers
|
||||||
|
|
||||||
|
Once Pay/Pay-All buttons are JS-driven, the helpers in
|
||||||
|
[render.go:63-85](go/internal/web/render.go#L63-L85) have no callers.
|
||||||
|
**Decision: delete them**, plus their entries in `tmplFuncs`. A
|
||||||
|
no-JS fallback is not on the roadmap; if it ever becomes one, the
|
||||||
|
helpers are easy to resurrect from git.
|
||||||
|
|
||||||
|
The unit-test file currently has no direct tests for these helpers —
|
||||||
|
nothing to remove from tests.
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
| Action | Path |
|
||||||
|
|--------|------|
|
||||||
|
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — Pay/Pay-All → `<button data-…>`; add `#qrModal`; add `data-bank-account` on filter container; add `<script src="/static/js/payment-qr.js" defer>` |
|
||||||
|
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
|
||||||
|
| new | `go/internal/web/static/js/payment-qr.js` — modal module |
|
||||||
|
| edit | [go/internal/web/render.go](go/internal/web/render.go) — drop `qrHref`, `qrHrefAll`, and their `tmplFuncs` entries (and the `net/url`, `strconv` imports if no longer needed) |
|
||||||
|
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — verify / add `#qrModal { display:none; }` + `#qrModal.active { display:flex; }` rules |
|
||||||
|
| edit | [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go) — markup assertions |
|
||||||
|
|
||||||
|
No changes to: `assets.go` (`embed.FS` glob picks up new JS automatically),
|
||||||
|
`server.go`, `html_handler.go`, the `/qr` handler, `cmd/fuj/main.go`, the
|
||||||
|
API handlers, or any domain code.
|
||||||
|
|
||||||
|
## Reused existing infrastructure
|
||||||
|
|
||||||
|
- [go/internal/web/qr.go](go/internal/web/qr.go) — `/qr?account=…&amount=…&message=…` endpoint serves the PNG already
|
||||||
|
- [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — modal CSS already lifted whole
|
||||||
|
- [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js) — IIFE / `'use strict'` / `data-*` style to mirror
|
||||||
|
- [go/internal/web/api/types.go](go/internal/web/api/types.go) — `MemberRow.UnpaidPeriods` and `RawUnpaidPeriods` already populated for Pay-All
|
||||||
|
- [templates/adults.html:631-648,963-986](templates/adults.html#L631-L648) — Python reference for both modal markup and `showPayQR` JS (logic to port, not literal copy)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end, after copying the plan into `docs/plans/`:
|
||||||
|
|
||||||
|
1. `cd go && make go-build go-test go-lint` — all green.
|
||||||
|
2. `make web-go &` — boot Go on `:8080`.
|
||||||
|
3. Browser: `http://localhost:8080/adults`
|
||||||
|
- Click `Pay` on any unpaid month → `#qrModal` opens with
|
||||||
|
- title `Payment for <Month YYYY>`,
|
||||||
|
- `qrAccount` matching `BANK_ACCOUNT`,
|
||||||
|
- `qrAmount` matching the cell amount,
|
||||||
|
- `qrMessage` formatted as `<Name>: MM/YYYY`,
|
||||||
|
- QR image rendered from `/qr?…` (Network tab shows PNG response).
|
||||||
|
- Tab does **not** navigate; URL stays `/adults`.
|
||||||
|
- Click `Pay All` → modal opens with `+`-joined `MM/YYYY+MM/YYYY` message.
|
||||||
|
- Click outside the modal-content → modal closes.
|
||||||
|
- Click `[close]` → modal closes.
|
||||||
|
- Press `Esc` → modal closes.
|
||||||
|
- Open `[i]` member-detail modal first, then `Esc` → only that closes
|
||||||
|
(verify both modals don't fight each other).
|
||||||
|
4. Browser: `http://localhost:8080/juniors` — same checks. Verify Pay
|
||||||
|
on a `"?"`-expected row doesn't appear (Pay button is conditional on
|
||||||
|
`unpaid`/`partial` cells).
|
||||||
|
5. Cross-check against Python: `make web-py &` →
|
||||||
|
`http://localhost:5001/adults` modal looks visually equivalent for
|
||||||
|
the same fixture (modulo any nav / theme differences already accepted
|
||||||
|
in M6).
|
||||||
|
6. Scan a QR with a Czech banking app (or `zbarimg` on the saved PNG) —
|
||||||
|
payload starts with `SPD*1.0*ACC:` and the message decodes to
|
||||||
|
`<Name>: MM/YYYY`.
|
||||||
|
7. After the user merges in Gitea: append CHANGELOG entry (timestamp via
|
||||||
|
`date "+%Y-%m-%d %H:%M %Z"`); add a new tick line for **M6.6.1** in
|
||||||
|
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||||
|
immediately under the M6.6 line, in the same format as the other
|
||||||
|
sub-bullets.
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-6-1-payment-qr-modal` off
|
||||||
|
`main`, commit with `Co-Authored-By` trailer, push with `-u`, open MR via
|
||||||
|
`tea pr create`. Do not merge or delete from CLI.
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- **Pay button no-JS fallback** — feasible to keep `qrHref` and have
|
||||||
|
the JS `preventDefault` an `<a>`, but no current need. Resurrect the
|
||||||
|
helpers if asked.
|
||||||
|
- **Modal pattern unification** — `#memberModal` and `#qrModal` use
|
||||||
|
inline `onclick` for `[close]` and outside-click. Consistent with
|
||||||
|
M6.5 but worth a follow-up to delegate-event everything.
|
||||||
|
- **Pay-from-modal inside `#memberModal`** — Python doesn't have it, and
|
||||||
|
no one's asked for it on the Go side.
|
||||||
|
- **Per-button QR error-correction / size** — `/qr` is fixed at
|
||||||
|
Medium/256, same as M6.6. Configurable via query param can be added if
|
||||||
|
printing larger physical posters becomes a use case.
|
||||||
|
- **`/payments` Pay buttons** — the payments page is a ledger view; it
|
||||||
|
doesn't render Pay buttons in either backend.
|
||||||
184
docs/plans/2026-05-11-2353-fill-first-multi-month-allocation.md
Normal file
184
docs/plans/2026-05-11-2353-fill-first-multi-month-allocation.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Fill-first multi-month payment allocation
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Matyáš Thér paid in two transactions:
|
||||||
|
|
||||||
|
| # | Amount | Purpose |
|
||||||
|
|---|--------|---------------------|
|
||||||
|
| 1 | 200 | `2026-02` |
|
||||||
|
| 2 | 550 | `2026-02, 2026-03` |
|
||||||
|
|
||||||
|
Total 750 = his expected fee for the two months (likely 2026-02 = 500, 2026-03 = 250 — junior tier or exception-adjusted). The app currently shows 2026-02 = **566** paid, 2026-03 = **183** paid. The user wants:
|
||||||
|
|
||||||
|
> First use the second payment for the rest of 2026-02 (no more, no less), then put the remainder toward 2026-03.
|
||||||
|
|
||||||
|
Both Python ([scripts/match_payments.py](scripts/match_payments.py)) and Go ([go/internal/domain/reconcile/reconcile.go](go/internal/domain/reconcile/reconcile.go)) have the same bug: when a single transaction's `in_window_share` is less than the sum of in-window expected fees, both fall into a **proportional** branch that splits the new transaction across months in proportion to each month's *total* expected fee — never consulting what prior transactions already paid into earlier months.
|
||||||
|
|
||||||
|
Trace for txn 2:
|
||||||
|
|
||||||
|
- `in_window = [(2026-02, 500), (2026-03, 250)]`, `total_expected = 750`, `in_window_share = 550`.
|
||||||
|
- `550 < 750` → proportional:
|
||||||
|
- 02 alloc = `550 × 500 / 750 = 366.67`
|
||||||
|
- 03 alloc = `550 − 366.67 = 183.33`
|
||||||
|
- Combined with txn 1's 200 → 02 = 566.67, 03 = 183.33 → display `566 / 183`. ✓ matches reported numbers.
|
||||||
|
|
||||||
|
The fix: replace the greedy + proportional branches with a single **fill-first** loop that iterates `in_window` in user-supplied order (already chronological by convention from [scripts/infer_payments.py:151](scripts/infer_payments.py#L151)) and allocates `min(remaining, max(0, expected − paid_so_far))` to each month, with any final surplus going to the credit bucket. This collapses three cases (greedy / proportional / pure overflow) into one and naturally consults the ledger's `paid` field which is already updated by prior transactions in the same reconcile pass.
|
||||||
|
|
||||||
|
The even-split branch (`total_expected == 0`, prepayment before fees known) stays untouched — different semantic, folding it in would silently change behavior.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Python — [scripts/match_payments.py:471-498](scripts/match_payments.py#L471-L498)
|
||||||
|
|
||||||
|
Replace both `total_expected > 0` branches (current lines 471–498) with a single unified loop. Keep lines 466–469 above and the even-split fallback below (lines 499–510) as-is.
|
||||||
|
|
||||||
|
```python
|
||||||
|
if total_expected > 0:
|
||||||
|
# Fill-first: iterate in_window in matched_months order (chronological by
|
||||||
|
# convention), allocate min(remaining, deficit) to each month, where
|
||||||
|
# deficit accounts for prior transactions already credited to that month.
|
||||||
|
# Any surplus after all in-window deficits are covered → credit bucket.
|
||||||
|
remaining = in_window_share
|
||||||
|
for m, exp in in_window:
|
||||||
|
paid_so_far = ledger[member_name][m]["paid"]
|
||||||
|
deficit = max(0.0, float(exp) - paid_so_far)
|
||||||
|
alloc = min(remaining, deficit)
|
||||||
|
if alloc <= 0:
|
||||||
|
continue
|
||||||
|
ledger[member_name][m]["paid"] += alloc
|
||||||
|
ledger[member_name][m]["transactions"].append({
|
||||||
|
"amount": alloc,
|
||||||
|
"date": tx["date"],
|
||||||
|
"sender": tx["sender"],
|
||||||
|
"message": tx["message"],
|
||||||
|
"confidence": confidence,
|
||||||
|
})
|
||||||
|
remaining -= alloc
|
||||||
|
if remaining > 0:
|
||||||
|
credits[member_name] = credits.get(member_name, 0) + int(remaining)
|
||||||
|
else:
|
||||||
|
# … existing even-split branch (lines 499–510) unchanged …
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: skipping the `transactions.append` when `alloc <= 0` (e.g. month already fully paid by a prior txn) avoids zero-amount ghost rows in the per-month transaction list. This is a small UI-visible side effect; before committing, grep tests for assertions on `len(transactions)` per month to confirm nothing relies on the current "one row per (txn, month) regardless of alloc" behavior.
|
||||||
|
|
||||||
|
### Go — [go/internal/domain/reconcile/reconcile.go:320-357](go/internal/domain/reconcile/reconcile.go#L320-L357)
|
||||||
|
|
||||||
|
Same shape — replace both `totalExpected > 0` branches. Even-split branch (lines 358–372) stays.
|
||||||
|
|
||||||
|
```go
|
||||||
|
if totalExpected > 0 {
|
||||||
|
// Fill-first; see Python reconcile() for rationale.
|
||||||
|
remaining := inWindowShare
|
||||||
|
for _, mw := range inWindow {
|
||||||
|
md := ledger[memberName][mw.month]
|
||||||
|
deficit := float64(mw.expected) - md.Paid
|
||||||
|
if deficit < 0 {
|
||||||
|
deficit = 0
|
||||||
|
}
|
||||||
|
alloc := remaining
|
||||||
|
if deficit < alloc {
|
||||||
|
alloc = deficit
|
||||||
|
}
|
||||||
|
if alloc <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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
|
||||||
|
remaining -= alloc
|
||||||
|
}
|
||||||
|
if remaining > 0 {
|
||||||
|
credits[memberName] += int(remaining)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// … existing even-split branch (lines 358–372) unchanged …
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
#### Python — [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py)
|
||||||
|
|
||||||
|
1. **Rewrite `test_proportional_underpayment`** ([line 96](tests/test_reconcile_exceptions.py#L96)) — its current assertions (`paid_02 < 750`, `paid_03 < 350`, `paid_04 < 750`, and 02/04 equal allocation) are incompatible with fill-first. Under fill-first with the same fixture (1250 across `02:750, 03:350, 04:750`):
|
||||||
|
- 02: `min(1250, 750) = 750` (full) → remaining 500
|
||||||
|
- 03: `min(500, 350) = 350` (full) → remaining 150
|
||||||
|
- 04: `min(150, 750) = 150` (partial) → remaining 0
|
||||||
|
|
||||||
|
Replace assertions with these exact expected values, rename to `test_underpayment_fills_earliest_first`.
|
||||||
|
|
||||||
|
2. **Add `test_fill_first_across_two_transactions`** — the Matyáš regression:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_fill_first_across_two_transactions(self):
|
||||||
|
"""Prior txn fills 02 partially; later txn finishes 02 then spills to 03."""
|
||||||
|
members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})]
|
||||||
|
sorted_months = ['2026-02', '2026-03']
|
||||||
|
tx1 = _tx('Matyáš', '2026-02', 200)
|
||||||
|
tx2 = _tx('Matyáš', '2026-02, 2026-03', 550)
|
||||||
|
|
||||||
|
result = reconcile(members, sorted_months, [tx1, tx2])
|
||||||
|
months = result['members']['Matyáš']['months']
|
||||||
|
|
||||||
|
self.assertAlmostEqual(months['2026-02']['paid'], 500, places=2)
|
||||||
|
self.assertAlmostEqual(months['2026-03']['paid'], 250, places=2)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. `test_greedy_exact_match`, `test_greedy_overpayment_goes_to_credit`, `test_single_month_unchanged`, `test_two_members_multi_month` should pass unchanged — fill-first agrees with greedy when payment ≥ total expected.
|
||||||
|
|
||||||
|
#### Go — [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go)
|
||||||
|
|
||||||
|
- Add `TestUnderpaymentFillsEarliestFirst` mirroring the rewritten Python test.
|
||||||
|
- Add `TestFillFirstAcrossTwoTransactions` mirroring the Matyáš scenario.
|
||||||
|
|
||||||
|
#### Parity — [go/tests/parity/reconcile/reconcile_parity_test.go](go/tests/parity/reconcile/reconcile_parity_test.go)
|
||||||
|
|
||||||
|
Add a fixture for the Matyáš two-transaction case. Since both implementations change together and Python remains canonical, existing parity fixtures should continue to pass; verify after edits.
|
||||||
|
|
||||||
|
### Changelog — [CHANGELOG.md](CHANGELOG.md)
|
||||||
|
|
||||||
|
Append top entry (use `date "+%Y-%m-%d %H:%M %Z"` at commit time):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 2026-05-11 23:55 CET — fill-first multi-month payment allocation
|
||||||
|
|
||||||
|
- Multi-month payment allocation now fills the earliest in-window deficit first
|
||||||
|
and spills the remainder to later months, accounting for prior transactions'
|
||||||
|
contributions. Previously a single transaction was split proportionally to
|
||||||
|
each month's total expected fee, ignoring earlier payments — surfaced by
|
||||||
|
Matyáš Thér's two-payment 200+550 case showing 566/183 instead of 500/250.
|
||||||
|
- scripts/match_payments.py, go/internal/domain/reconcile/reconcile.go, tests.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
- [scripts/match_payments.py](scripts/match_payments.py) — Python reconcile (canonical)
|
||||||
|
- [go/internal/domain/reconcile/reconcile.go](go/internal/domain/reconcile/reconcile.go) — Go reconcile (mirrors Python)
|
||||||
|
- [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py) — rewrite `test_proportional_underpayment` + add Matyáš test
|
||||||
|
- [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go) — add Go tests
|
||||||
|
- [go/tests/parity/reconcile/reconcile_parity_test.go](go/tests/parity/reconcile/reconcile_parity_test.go) — add parity fixture
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md) — top entry
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `make test` — Python unit tests including the rewritten + new fill-first tests.
|
||||||
|
2. `cd go && go test ./internal/domain/reconcile/... ./tests/parity/reconcile/...` — Go unit + parity tests.
|
||||||
|
3. `make web` → load `/adults` or `/juniors` (whichever lists Matyáš Thér) → his 2026-02 row should be fully paid (no shortfall), 2026-03 fully paid, no leftover credit.
|
||||||
|
4. Spot-check one other multi-month-purpose member to make sure fully-covered cases still look right.
|
||||||
|
|
||||||
|
## Branch & MR (per CLAUDE.md)
|
||||||
|
|
||||||
|
1. `git checkout -b fix/fill-first-multi-month-allocation`
|
||||||
|
2. Apply edits + tests + CHANGELOG entry.
|
||||||
|
3. `make test && (cd go && go test ./...)` — both green.
|
||||||
|
4. Commit: `fix(reconcile): fill earliest month deficit first in multi-month allocations` with `Co-Authored-By` trailer.
|
||||||
|
5. `git push -u origin fix/fill-first-multi-month-allocation`
|
||||||
|
6. `tea pr create --title "fix(reconcile): fill earliest month deficit first" --description "<body>" --base main --head fix/fill-first-multi-month-allocation`
|
||||||
|
7. Print MR URL. Do not merge from CLI.
|
||||||
@@ -115,10 +115,11 @@ type monthExpected struct {
|
|||||||
expected int
|
expected int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile matches transactions to members and months using three allocation phases:
|
// Reconcile matches transactions to members and months using two allocation phases:
|
||||||
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit.
|
// 1. Fill-first: iterate matched months in user-supplied order, allocating min(remaining,
|
||||||
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder.
|
// deficit) to each month where deficit = expected − already-paid. Surplus → credit.
|
||||||
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
// Handles both the "greedy" (payment covers all) and "partial" cases in one pass.
|
||||||
|
// 2. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
|
||||||
//
|
//
|
||||||
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
||||||
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
||||||
@@ -317,34 +318,26 @@ func Reconcile(
|
|||||||
totalExpected += mw.expected
|
totalExpected += mw.expected
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalExpected > 0 && inWindowShare >= float64(totalExpected) {
|
if totalExpected > 0 {
|
||||||
// Greedy: payment covers all expected fees; overflow → credit
|
// Fill-first: iterate inWindow in matched-months order (chronological by
|
||||||
credits[memberName] += int(inWindowShare - float64(totalExpected))
|
// convention), allocating min(remaining, deficit) to each month. Deficit
|
||||||
for _, mw := range inWindow {
|
// is net of what prior transactions already paid, so a second payment on
|
||||||
alloc := float64(mw.expected)
|
// the same months correctly fills only what remains due. Any surplus after
|
||||||
md := ledger[memberName][mw.month]
|
// all deficits are covered goes to the credit bucket.
|
||||||
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
|
remaining := inWindowShare
|
||||||
for i, mw := range inWindow {
|
for _, 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 := ledger[memberName][mw.month]
|
||||||
|
deficit := float64(mw.expected) - md.Paid
|
||||||
|
if deficit < 0 {
|
||||||
|
deficit = 0
|
||||||
|
}
|
||||||
|
alloc := remaining
|
||||||
|
if deficit < alloc {
|
||||||
|
alloc = deficit
|
||||||
|
}
|
||||||
|
if alloc <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
md.Paid += alloc
|
md.Paid += alloc
|
||||||
md.Transactions = append(md.Transactions, TxEntry{
|
md.Transactions = append(md.Transactions, TxEntry{
|
||||||
Amount: alloc,
|
Amount: alloc,
|
||||||
@@ -354,6 +347,10 @@ func Reconcile(
|
|||||||
Confidence: string(m.Confidence),
|
Confidence: string(m.Confidence),
|
||||||
})
|
})
|
||||||
ledger[memberName][mw.month] = md
|
ledger[memberName][mw.month] = md
|
||||||
|
remaining -= alloc
|
||||||
|
}
|
||||||
|
if remaining > 0 {
|
||||||
|
credits[memberName] += int(remaining)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Even-split fallback: prepayment before attendance recorded
|
// Even-split fallback: prepayment before attendance recorded
|
||||||
|
|||||||
@@ -111,36 +111,26 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileProportionalUnderpayment(t *testing.T) {
|
func TestReconcileUnderpaymentFillsEarliestFirst(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
Name: "Alice", Tier: "A",
|
||||||
Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}},
|
Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}},
|
||||||
}}
|
}}
|
||||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
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)
|
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, nil, defaultYear)
|
||||||
|
|
||||||
months := result.Members["Alice"].Months
|
months := result.Members["Alice"].Months
|
||||||
paid02 := months["2026-02"].Paid
|
// 02 filled first (750), then 03 (350), then remainder 150 to 04
|
||||||
paid03 := months["2026-03"].Paid
|
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
|
||||||
paid04 := months["2026-04"].Paid
|
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
|
||||||
|
|
||||||
if paid02 >= 750 {
|
|
||||||
t.Errorf("2026-02 should be underpaid, got %f", paid02)
|
|
||||||
}
|
}
|
||||||
if paid03 >= 350 {
|
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
|
||||||
t.Errorf("2026-03 should be underpaid, got %f", paid03)
|
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
|
||||||
}
|
}
|
||||||
if paid04 >= 750 {
|
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
|
||||||
t.Errorf("2026-04 should be underpaid, got %f", paid04)
|
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,3 +364,52 @@ func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
|||||||
t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched)
|
t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payment < total expected → fill earliest months first, spill remainder to later.
|
||||||
|
func TestUnderpaymentFillsEarliestFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{
|
||||||
|
"2026-02": {Expected: 750, Attendance: 3},
|
||||||
|
"2026-03": {Expected: 350, Attendance: 3},
|
||||||
|
"2026-04": {Expected: 750, Attendance: 3},
|
||||||
|
}}}
|
||||||
|
txs := []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-02", "2026-03", "2026-04"}, txs, nil, defaultYear)
|
||||||
|
months := result.Members["Alice"].Months
|
||||||
|
|
||||||
|
// 02 filled first (750), then 03 (350), then remainder 150 to 04
|
||||||
|
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
|
||||||
|
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
|
||||||
|
}
|
||||||
|
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
|
||||||
|
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
|
||||||
|
}
|
||||||
|
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
|
||||||
|
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prior txn fills 02 partially; later txn finishes 02 then spills to 03.
|
||||||
|
func TestFillFirstAcrossTwoTransactions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Matyáš", Tier: "A", Fees: map[string]FeeData{
|
||||||
|
"2026-02": {Expected: 500, Attendance: 2},
|
||||||
|
"2026-03": {Expected: 250, Attendance: 1},
|
||||||
|
}}}
|
||||||
|
sortedMonths := []string{"2026-02", "2026-03"}
|
||||||
|
txs := []Transaction{
|
||||||
|
tx("Matyáš", "2026-02", 200),
|
||||||
|
tx("Matyáš", "2026-02, 2026-03", 550),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Reconcile(members, sortedMonths, txs, nil, defaultYear)
|
||||||
|
months := result.Members["Matyáš"].Months
|
||||||
|
|
||||||
|
if math.Abs(months["2026-02"].Paid-500) > 0.01 {
|
||||||
|
t.Errorf("02: want 500, got %f", months["2026-02"].Paid)
|
||||||
|
}
|
||||||
|
if math.Abs(months["2026-03"].Paid-250) > 0.01 {
|
||||||
|
t.Errorf("03: want 250, got %f", months["2026-03"].Paid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -314,6 +314,52 @@ func TestServeSync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPaymentQRMarkup(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
}{
|
||||||
|
{"/adults", h.ServeAdults},
|
||||||
|
{"/juniors", h.ServeJuniors},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
tc.handler(w, req)
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="qrModal"`,
|
||||||
|
`id="qrImg"`,
|
||||||
|
`id="qrTitle"`,
|
||||||
|
`id="qrAccount"`,
|
||||||
|
`id="qrAmount"`,
|
||||||
|
`id="qrMessage"`,
|
||||||
|
`/static/js/payment-qr.js`,
|
||||||
|
`data-bank-account="CZ0000000000000000000000"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay buttons must use <button>, never a bare href to /qr.
|
||||||
|
if strings.Contains(body, `href="/qr`) {
|
||||||
|
t.Error("body must not contain href=/qr (Pay buttons should be <button>, not <a>)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeVersion(t *testing.T) {
|
func TestServeVersion(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PageData is the view model passed to every HTML template.
|
// PageData is the view model passed to every HTML template.
|
||||||
@@ -58,36 +56,7 @@ type Renderer struct {
|
|||||||
|
|
||||||
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
||||||
|
|
||||||
// qrHref builds the /qr query URL for a single-month Pay button.
|
var tmplFuncs = template.FuncMap{}
|
||||||
// rawMonth is "YYYY-MM"; it is converted to "MM/YYYY" in the QR message.
|
|
||||||
func qrHref(account string, amount int, name, rawMonth string) string {
|
|
||||||
// Convert "YYYY-MM" → "MM/YYYY" to match Python's showPayQR JS.
|
|
||||||
if len(rawMonth) == 7 && rawMonth[4] == '-' {
|
|
||||||
rawMonth = rawMonth[5:] + "/" + rawMonth[:4]
|
|
||||||
}
|
|
||||||
msg := name + ": " + rawMonth
|
|
||||||
return "/qr?" + url.Values{
|
|
||||||
"account": {account},
|
|
||||||
"amount": {strconv.Itoa(amount)},
|
|
||||||
"message": {msg},
|
|
||||||
}.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
// qrHrefAll builds the /qr query URL for a Pay-All button.
|
|
||||||
// rawPeriods is the "+" -joined MM/YYYY string from MemberRow.RawUnpaidPeriods.
|
|
||||||
func qrHrefAll(account string, amount int, name, rawPeriods string) string {
|
|
||||||
msg := name + ": " + rawPeriods
|
|
||||||
return "/qr?" + url.Values{
|
|
||||||
"account": {account},
|
|
||||||
"amount": {strconv.Itoa(amount)},
|
|
||||||
"message": {msg},
|
|
||||||
}.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
var tmplFuncs = template.FuncMap{
|
|
||||||
"qrHref": qrHref,
|
|
||||||
"qrHrefAll": qrHrefAll,
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRenderer parses all templates from the embedded FS.
|
// NewRenderer parses all templates from the embedded FS.
|
||||||
// A parse failure should be treated as a startup-time fatal error.
|
// A parse failure should be treated as a startup-time fatal error.
|
||||||
|
|||||||
@@ -442,6 +442,23 @@ tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* QR Modal styles */
|
/* QR Modal styles */
|
||||||
|
#qrModal {
|
||||||
|
display: none !important;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#qrModal.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
#qrModal .modal-content {
|
#qrModal .modal-content {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
60
go/internal/web/static/js/payment-qr.js
Normal file
60
go/internal/web/static/js/payment-qr.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
const bankAccount = container.dataset.bankAccount || '';
|
||||||
|
|
||||||
|
const modal = document.getElementById('qrModal');
|
||||||
|
const titleEl = document.getElementById('qrTitle');
|
||||||
|
const imgEl = document.getElementById('qrImg');
|
||||||
|
const accountEl = document.getElementById('qrAccount');
|
||||||
|
const amountEl = document.getElementById('qrAmount');
|
||||||
|
const messageEl = document.getElementById('qrMessage');
|
||||||
|
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
|
||||||
|
|
||||||
|
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges converted piece-wise.
|
||||||
|
// Mirrors templates/adults.html showPayQR logic.
|
||||||
|
function toCzechMonth(rawMonth) {
|
||||||
|
return rawMonth.split('+')
|
||||||
|
.map(function (p) { return p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'); })
|
||||||
|
.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQR(name, amount, month, rawMonth) {
|
||||||
|
var numericMonth = toCzechMonth(rawMonth);
|
||||||
|
var message = name + ': ' + numericMonth;
|
||||||
|
|
||||||
|
titleEl.innerText = 'Payment for ' + month;
|
||||||
|
accountEl.innerText = bankAccount;
|
||||||
|
amountEl.innerText = amount;
|
||||||
|
messageEl.innerText = message;
|
||||||
|
|
||||||
|
imgEl.src = '/qr?'
|
||||||
|
+ 'account=' + encodeURIComponent(bankAccount)
|
||||||
|
+ '&amount=' + encodeURIComponent(amount)
|
||||||
|
+ '&message=' + encodeURIComponent(message);
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQR() {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
var btn = ev.target.closest('.pay-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
showQR(
|
||||||
|
btn.dataset.name,
|
||||||
|
btn.dataset.amount,
|
||||||
|
btn.dataset.month,
|
||||||
|
btn.dataset.rawMonth
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults">
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}">
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label class="filter-label" for="nameFilter">Member</label>
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
@@ -62,14 +62,14 @@
|
|||||||
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
||||||
{{$cell.Text}}
|
{{$cell.Text}}
|
||||||
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
||||||
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
{{end}}
|
{{end}}
|
||||||
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
||||||
{{$row.Balance}}
|
{{$row.Balance}}
|
||||||
{{if gt $row.PayableAmount 0}}
|
{{if gt $row.PayableAmount 0}}
|
||||||
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -184,6 +184,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/filters.js" defer></script>
|
<script src="/static/js/filters.js" defer></script>
|
||||||
<script src="/static/js/member-detail.js" defer></script>
|
<script src="/static/js/member-detail.js" defer></script>
|
||||||
|
<script src="/static/js/payment-qr.js" defer></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors">
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}">
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label class="filter-label" for="nameFilter">Member</label>
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
@@ -62,14 +62,14 @@
|
|||||||
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
||||||
{{$cell.Text}}
|
{{$cell.Text}}
|
||||||
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
||||||
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
{{end}}
|
{{end}}
|
||||||
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
||||||
{{$row.Balance}}
|
{{$row.Balance}}
|
||||||
{{if gt $row.PayableAmount 0}}
|
{{if gt $row.PayableAmount 0}}
|
||||||
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
|
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -164,6 +164,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="qrTitle">Payment for —</div>
|
||||||
|
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-image">
|
||||||
|
<img id="qrImg" src="" alt="Payment QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="qr-details">
|
||||||
|
<div>Account: <span id="qrAccount"></span></div>
|
||||||
|
<div>Amount: <span id="qrAmount"></span> CZK</div>
|
||||||
|
<div>Message: <span id="qrMessage"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/filters.js" defer></script>
|
<script src="/static/js/filters.js" defer></script>
|
||||||
<script src="/static/js/member-detail.js" defer></script>
|
<script src="/static/js/member-detail.js" defer></script>
|
||||||
|
<script src="/static/js/payment-qr.js" defer></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"case": "03_proportional_remainder",
|
"case": "03_proportional_remainder",
|
||||||
"func": "scripts.match_payments.reconcile",
|
"func": "scripts.match_payments.reconcile",
|
||||||
"captured_at": "2026-05-06",
|
"captured_at": "2026-05-11",
|
||||||
"input": {
|
"input": {
|
||||||
"members": [
|
"members": [
|
||||||
{
|
{
|
||||||
@@ -54,10 +54,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 3,
|
"attendance_count": 3,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 324.3243243243243,
|
"paid": 750.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 324.3243243243243,
|
"amount": 750.0,
|
||||||
"date": "2026-03-10",
|
"date": "2026-03-10",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -70,10 +70,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 324.3243243243243,
|
"paid": 50.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 324.3243243243243,
|
"amount": 50.0,
|
||||||
"date": "2026-03-10",
|
"date": "2026-03-10",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -86,25 +86,17 @@
|
|||||||
"original_expected": 350,
|
"original_expected": 350,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 151.35135135135135,
|
"paid": 0,
|
||||||
"transactions": [
|
"transactions": []
|
||||||
{
|
|
||||||
"amount": 151.35135135135135,
|
|
||||||
"date": "2026-03-10",
|
|
||||||
"sender": "Member_d035d9f9",
|
|
||||||
"message": "",
|
|
||||||
"confidence": "auto"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other_transactions": [],
|
"other_transactions": [],
|
||||||
"total_balance": -1051
|
"total_balance": -1050
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"unmatched": [],
|
"unmatched": [],
|
||||||
"credits": {
|
"credits": {
|
||||||
"Member_d035d9f9": -1051
|
"Member_d035d9f9": -1050
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"case": "09_multiperson_multimonth",
|
"case": "09_multiperson_multimonth",
|
||||||
"func": "scripts.match_payments.reconcile",
|
"func": "scripts.match_payments.reconcile",
|
||||||
"captured_at": "2026-05-06",
|
"captured_at": "2026-05-11",
|
||||||
"input": {
|
"input": {
|
||||||
"members": [
|
"members": [
|
||||||
{
|
{
|
||||||
@@ -63,10 +63,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 3,
|
"attendance_count": 3,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 500.0,
|
"paid": 750.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 500.0,
|
"amount": 750.0,
|
||||||
"date": "2026-02-15",
|
"date": "2026-02-15",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -79,10 +79,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 500.0,
|
"paid": 250.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 500.0,
|
"amount": 250.0,
|
||||||
"date": "2026-02-15",
|
"date": "2026-02-15",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -102,10 +102,10 @@
|
|||||||
"original_expected": 750,
|
"original_expected": 750,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 681.8181818181819,
|
"paid": 750.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 681.8181818181819,
|
"amount": 750.0,
|
||||||
"date": "2026-02-15",
|
"date": "2026-02-15",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -118,10 +118,10 @@
|
|||||||
"original_expected": 350,
|
"original_expected": 350,
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"exception": null,
|
"exception": null,
|
||||||
"paid": 318.18181818181813,
|
"paid": 250.0,
|
||||||
"transactions": [
|
"transactions": [
|
||||||
{
|
{
|
||||||
"amount": 318.18181818181813,
|
"amount": 250.0,
|
||||||
"date": "2026-02-15",
|
"date": "2026-02-15",
|
||||||
"sender": "Member_d035d9f9",
|
"sender": "Member_d035d9f9",
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -131,13 +131,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other_transactions": [],
|
"other_transactions": [],
|
||||||
"total_balance": -101
|
"total_balance": -100
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"unmatched": [],
|
"unmatched": [],
|
||||||
"credits": {
|
"credits": {
|
||||||
"Member_d035d9f9": -500,
|
"Member_d035d9f9": -500,
|
||||||
"Member_f4a93e46": -101
|
"Member_f4a93e46": -100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
@@ -89,9 +90,11 @@ def parse_czech_amount(s: str) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def parse_czech_date(s: str) -> str | None:
|
def parse_czech_date(s: str) -> str | None:
|
||||||
"""Parse 'DD.MM.YYYY' to 'YYYY-MM-DD'."""
|
"""Parse a Czech date to 'YYYY-MM-DD'. Accepts 4-digit and 2-digit years
|
||||||
|
with dot or slash separators; Fio's transparent page mixes 'DD.MM.YYYY'
|
||||||
|
and 'DD.MM.YY' in the same response."""
|
||||||
s = s.strip()
|
s = s.strip()
|
||||||
for fmt in ("%d.%m.%Y", "%d/%m/%Y"):
|
for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%d.%m.%y", "%d/%m/%y"):
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -146,6 +149,7 @@ def fetch_transactions_transparent(
|
|||||||
"bank_id": "", # HTML scraping doesn't give stable ID
|
"bank_id": "", # HTML scraping doesn't give stable ID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
print(f"fio: transparent fetched {len(rows)} raw rows, {len(transactions)} transaction(s) after filtering", file=sys.stderr)
|
||||||
return transactions
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
@@ -169,7 +173,8 @@ def fetch_transactions_api(
|
|||||||
|
|
||||||
transactions = []
|
transactions = []
|
||||||
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
tx_list = data.get("accountStatement", {}).get("transactionList", {})
|
||||||
for tx in (tx_list.get("transaction") or []):
|
raw_list = tx_list.get("transaction") or []
|
||||||
|
for tx in raw_list:
|
||||||
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
# Each field is {"value": ..., "name": ..., "id": ...} or null
|
||||||
def val(col_id):
|
def val(col_id):
|
||||||
col = tx.get(f"column{col_id}")
|
col = tx.get(f"column{col_id}")
|
||||||
@@ -197,6 +202,7 @@ def fetch_transactions_api(
|
|||||||
"currency": str(val(14) or "CZK"), # column14 = Currency
|
"currency": str(val(14) or "CZK"), # column14 = Currency
|
||||||
})
|
})
|
||||||
|
|
||||||
|
print(f"fio: api fetched {len(raw_list)} raw transaction(s), {len(transactions)} after filtering", file=sys.stderr)
|
||||||
return transactions
|
return transactions
|
||||||
|
|
||||||
|
|
||||||
@@ -204,8 +210,14 @@ def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
|
|||||||
"""Fetch transactions, using API if token available, else transparent page."""
|
"""Fetch transactions, using API if token available, else transparent page."""
|
||||||
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
token = os.environ.get("FIO_API_TOKEN", "").strip()
|
||||||
if token:
|
if token:
|
||||||
|
print(f"fio: using authenticated API, window {date_from}..{date_to}", file=sys.stderr)
|
||||||
return fetch_transactions_api(token, date_from, date_to)
|
return fetch_transactions_api(token, date_from, date_to)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"fio: using transparent page (FIO_API_TOKEN unset — expect publishing lag), "
|
||||||
|
f"window {date_from}..{date_to}, account=2800359168",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
|
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
|
||||||
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
|
||||||
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
|
||||||
|
|||||||
@@ -468,26 +468,19 @@ def reconcile(
|
|||||||
|
|
||||||
total_expected = sum(e for _, e in in_window)
|
total_expected = sum(e for _, e in in_window)
|
||||||
|
|
||||||
if total_expected > 0 and in_window_share >= total_expected:
|
if total_expected > 0:
|
||||||
# Greedy phase: payment covers all in-window fees; overflow → credit.
|
# Fill-first: iterate in_window in matched_months order (chronological by
|
||||||
credits[member_name] = credits.get(member_name, 0) + int(in_window_share - total_expected)
|
# convention from infer_payments.py), allocating min(remaining, deficit) to
|
||||||
for m, exp in in_window:
|
# each month. Deficit is net of what prior transactions already paid, so a
|
||||||
alloc = float(exp)
|
# second payment on the same months correctly fills only what remains due.
|
||||||
ledger[member_name][m]["paid"] += alloc
|
# Any surplus after all deficits are covered goes to the credit bucket.
|
||||||
ledger[member_name][m]["transactions"].append({
|
|
||||||
"amount": alloc,
|
|
||||||
"date": tx["date"],
|
|
||||||
"sender": tx["sender"],
|
|
||||||
"message": tx["message"],
|
|
||||||
"confidence": confidence,
|
|
||||||
})
|
|
||||||
elif total_expected > 0:
|
|
||||||
# Proportional phase: distribute in_window_share by each month's expected fee.
|
|
||||||
# Last month absorbs any float remainder so the sum equals in_window_share exactly.
|
|
||||||
remaining = in_window_share
|
remaining = in_window_share
|
||||||
for i, (m, exp) in enumerate(in_window):
|
for m, exp in in_window:
|
||||||
alloc = remaining if i == len(in_window) - 1 else in_window_share * exp / total_expected
|
paid_so_far = ledger[member_name][m]["paid"]
|
||||||
remaining -= alloc
|
deficit = max(0.0, float(exp) - paid_so_far)
|
||||||
|
alloc = min(remaining, deficit)
|
||||||
|
if alloc <= 0:
|
||||||
|
continue
|
||||||
ledger[member_name][m]["paid"] += alloc
|
ledger[member_name][m]["paid"] += alloc
|
||||||
ledger[member_name][m]["transactions"].append({
|
ledger[member_name][m]["transactions"].append({
|
||||||
"amount": alloc,
|
"amount": alloc,
|
||||||
@@ -496,6 +489,9 @@ def reconcile(
|
|||||||
"message": tx["message"],
|
"message": tx["message"],
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
})
|
})
|
||||||
|
remaining -= alloc
|
||||||
|
if remaining > 0:
|
||||||
|
credits[member_name] = credits.get(member_name, 0) + int(remaining)
|
||||||
else:
|
else:
|
||||||
# Fallback: no expected fees (prepayment before attendance recorded); even split.
|
# Fallback: no expected fees (prepayment before attendance recorded); even split.
|
||||||
per_month = in_window_share / len(in_window)
|
per_month = in_window_share / len(in_window)
|
||||||
|
|||||||
@@ -77,6 +77,35 @@ def generate_sync_id(tx: dict) -> str:
|
|||||||
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _trunc(s: str, n: int = 40) -> str:
|
||||||
|
s = str(s)
|
||||||
|
return s if len(s) <= n else s[: n - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_fio_table(transactions: list[dict], statuses: list[str]) -> None:
|
||||||
|
headers = ["DATE", "AMOUNT", "SENDER", "VS", "MESSAGE", "BANKID", "STATUS"]
|
||||||
|
rows = [
|
||||||
|
[
|
||||||
|
str(tx.get("date", "")),
|
||||||
|
f"{float(tx.get('amount', 0)):.2f}",
|
||||||
|
str(tx.get("sender", "")),
|
||||||
|
str(tx.get("vs", "")),
|
||||||
|
_trunc(str(tx.get("message", ""))),
|
||||||
|
str(tx.get("bank_id", "")),
|
||||||
|
status,
|
||||||
|
]
|
||||||
|
for tx, status in zip(transactions, statuses)
|
||||||
|
]
|
||||||
|
widths = [
|
||||||
|
max(len(headers[i]), max((len(r[i]) for r in rows), default=0))
|
||||||
|
for i in range(len(headers))
|
||||||
|
]
|
||||||
|
sep = " "
|
||||||
|
print(sep.join(h.ljust(w) for h, w in zip(headers, widths)))
|
||||||
|
for row in rows:
|
||||||
|
print(sep.join(cell.ljust(w) for cell, w in zip(row, widths)))
|
||||||
|
|
||||||
|
|
||||||
def sort_sheet_by_date(service, spreadsheet_id):
|
def sort_sheet_by_date(service, spreadsheet_id):
|
||||||
"""Sort the sheet by the Date column (Column B)."""
|
"""Sort the sheet by the Date column (Column B)."""
|
||||||
# Get the sheet ID (gid) of the first sheet
|
# Get the sheet ID (gid) of the first sheet
|
||||||
@@ -104,12 +133,21 @@ def sort_sheet_by_date(service, spreadsheet_id):
|
|||||||
print("Sheet sorted by date.")
|
print("Sheet sorted by date.")
|
||||||
|
|
||||||
|
|
||||||
def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None, date_from_str: str = None, date_to_str: str = None, sort_by_date: bool = False):
|
def sync_to_sheets(
|
||||||
|
spreadsheet_id: str,
|
||||||
|
credentials_path: str,
|
||||||
|
days: int = None,
|
||||||
|
date_from_str: str = None,
|
||||||
|
date_to_str: str = None,
|
||||||
|
sort_by_date: bool = False,
|
||||||
|
dry_run: bool = False,
|
||||||
|
print_fio_table: bool = False,
|
||||||
|
):
|
||||||
print(f"Connecting to Google Sheets using {credentials_path}...")
|
print(f"Connecting to Google Sheets using {credentials_path}...")
|
||||||
service = get_sheets_service(credentials_path)
|
service = get_sheets_service(credentials_path)
|
||||||
sheet = service.spreadsheets()
|
sheet = service.spreadsheets()
|
||||||
|
|
||||||
# 1. Fetch existing IDs from Column G (last column in A-G range)
|
# 1. Read existing sync IDs from Column K
|
||||||
print(f"Reading existing sync IDs from sheet...")
|
print(f"Reading existing sync IDs from sheet...")
|
||||||
try:
|
try:
|
||||||
result = sheet.values().get(
|
result = sheet.values().get(
|
||||||
@@ -117,19 +155,22 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
|
|||||||
range="A1:K" # Include header and all columns to check Sync ID
|
range="A1:K" # Include header and all columns to check Sync ID
|
||||||
).execute()
|
).execute()
|
||||||
values = result.get("values", [])
|
values = result.get("values", [])
|
||||||
|
|
||||||
# Check and insert labels if missing
|
# Check and insert labels if missing
|
||||||
if not values or values[0] != COLUMN_LABELS:
|
if not values or values[0] != COLUMN_LABELS:
|
||||||
print("Inserting column labels...")
|
if dry_run:
|
||||||
sheet.values().update(
|
print("Dry run: would write header row")
|
||||||
spreadsheetId=spreadsheet_id,
|
else:
|
||||||
range="A1",
|
print("Inserting column labels...")
|
||||||
valueInputOption="USER_ENTERED",
|
sheet.values().update(
|
||||||
body={"values": [COLUMN_LABELS]}
|
spreadsheetId=spreadsheet_id,
|
||||||
).execute()
|
range="A1",
|
||||||
|
valueInputOption="USER_ENTERED",
|
||||||
|
body={"values": [COLUMN_LABELS]}
|
||||||
|
).execute()
|
||||||
existing_ids = set()
|
existing_ids = set()
|
||||||
else:
|
else:
|
||||||
# Sync ID is now the last column (index 10)
|
# Sync ID is the last column (index 10)
|
||||||
existing_ids = {row[10] for row in values[1:] if len(row) > 10}
|
existing_ids = {row[10] for row in values[1:] if len(row) > 10}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading sheet (maybe empty?): {e}")
|
print(f"Error reading sheet (maybe empty?): {e}")
|
||||||
@@ -150,8 +191,12 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
|
|||||||
transactions = fetch_transactions(df_str, dt_str)
|
transactions = fetch_transactions(df_str, dt_str)
|
||||||
print(f"Found {len(transactions)} transactions.")
|
print(f"Found {len(transactions)} transactions.")
|
||||||
|
|
||||||
# 3. Filter for new transactions
|
if dry_run:
|
||||||
|
print(f"Dry run: window {df_str} to {dt_str}, fetched {len(transactions)} transaction(s) from Fio")
|
||||||
|
|
||||||
|
# 3. Determine NEW/DUP for each transaction
|
||||||
new_rows = []
|
new_rows = []
|
||||||
|
tx_statuses = []
|
||||||
for tx in transactions:
|
for tx in transactions:
|
||||||
sync_id = generate_sync_id(tx)
|
sync_id = generate_sync_id(tx)
|
||||||
if sync_id not in existing_ids:
|
if sync_id not in existing_ids:
|
||||||
@@ -169,24 +214,48 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
|
|||||||
tx.get("bank_id", ""),
|
tx.get("bank_id", ""),
|
||||||
sync_id,
|
sync_id,
|
||||||
])
|
])
|
||||||
|
tx_statuses.append("NEW")
|
||||||
|
else:
|
||||||
|
tx_statuses.append("DUP")
|
||||||
|
|
||||||
|
# 4. Print table (before early-return so all transactions are shown including DUPs)
|
||||||
|
if print_fio_table and transactions:
|
||||||
|
_print_fio_table(transactions, tx_statuses)
|
||||||
|
|
||||||
if not new_rows:
|
if not new_rows:
|
||||||
print("No new transactions to sync.")
|
if dry_run:
|
||||||
|
print("Dry run: would sync 0 new transaction(s).")
|
||||||
|
else:
|
||||||
|
print("No new transactions to sync.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 4. Append to sheet
|
# 5. Append to sheet or print dry-run would-write lines
|
||||||
print(f"Appending {len(new_rows)} new transactions to the sheet...")
|
if dry_run:
|
||||||
body = {"values": new_rows}
|
for tx, status in zip(transactions, tx_statuses):
|
||||||
sheet.values().append(
|
if status == "NEW":
|
||||||
spreadsheetId=spreadsheet_id,
|
print(
|
||||||
range="A2", # Appends to the end of the sheet
|
f"Dry run: would append"
|
||||||
valueInputOption="USER_ENTERED",
|
f" date={tx.get('date', '')}"
|
||||||
body=body
|
f" amount={tx.get('amount', '')}"
|
||||||
).execute()
|
f" sender={tx.get('sender', '')}"
|
||||||
print("Sync completed successfully.")
|
f" vs={tx.get('vs', '')}"
|
||||||
|
f" message={tx.get('message', '')}"
|
||||||
if sort_by_date:
|
)
|
||||||
sort_sheet_by_date(service, spreadsheet_id)
|
if sort_by_date:
|
||||||
|
print("Dry run: would sort by date")
|
||||||
|
print(f"Dry run: would sync {len(new_rows)} new transaction(s).")
|
||||||
|
else:
|
||||||
|
print(f"Appending {len(new_rows)} new transactions to the sheet...")
|
||||||
|
body = {"values": new_rows}
|
||||||
|
sheet.values().append(
|
||||||
|
spreadsheetId=spreadsheet_id,
|
||||||
|
range="A2", # Appends to the end of the sheet
|
||||||
|
valueInputOption="USER_ENTERED",
|
||||||
|
body=body
|
||||||
|
).execute()
|
||||||
|
print("Sync completed successfully.")
|
||||||
|
if sort_by_date:
|
||||||
|
sort_sheet_by_date(service, spreadsheet_id)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -197,16 +266,20 @@ def main():
|
|||||||
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
|
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
|
||||||
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
|
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
|
||||||
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
|
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Fetch and dedup without writing to the sheet")
|
||||||
|
parser.add_argument("--print-fio-table", action="store_true", help="Print aligned table of all fetched transactions with NEW/DUP status (use with --dry-run)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sync_to_sheets(
|
sync_to_sheets(
|
||||||
spreadsheet_id=args.sheet_id,
|
spreadsheet_id=args.sheet_id,
|
||||||
credentials_path=args.credentials,
|
credentials_path=args.credentials,
|
||||||
days=args.days,
|
days=args.days,
|
||||||
date_from_str=args.date_from,
|
date_from_str=args.date_from,
|
||||||
date_to_str=args.date_to,
|
date_to_str=args.date_to,
|
||||||
sort_by_date=args.sort_by_date
|
sort_by_date=args.sort_by_date,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
print_fio_table=args.print_fio_table,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Sync failed: {e}")
|
print(f"Sync failed: {e}")
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ class TestMultiMonthAllocation(unittest.TestCase):
|
|||||||
self.assertEqual(int(months['2026-02']['paid']), 750)
|
self.assertEqual(int(months['2026-02']['paid']), 750)
|
||||||
self.assertEqual(result['credits'].get('Alice', 0), 500)
|
self.assertEqual(result['credits'].get('Alice', 0), 500)
|
||||||
|
|
||||||
def test_proportional_underpayment(self):
|
def test_underpayment_fills_earliest_first(self):
|
||||||
"""Payment < total expected → proportional split; sum of paid == payment amount."""
|
"""Payment < total expected → fill earliest months first, spill remainder to later."""
|
||||||
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
|
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
|
||||||
sorted_months = ['2026-02', '2026-03', '2026-04']
|
sorted_months = ['2026-02', '2026-03', '2026-04']
|
||||||
amount = 1250
|
amount = 1250
|
||||||
@@ -103,18 +103,28 @@ class TestMultiMonthAllocation(unittest.TestCase):
|
|||||||
result = reconcile(members, sorted_months, [tx])
|
result = reconcile(members, sorted_months, [tx])
|
||||||
months = result['members']['Alice']['months']
|
months = result['members']['Alice']['months']
|
||||||
|
|
||||||
paid_02 = months['2026-02']['paid']
|
# 02 filled first (750), then 03 (350), then remainder 150 to 04
|
||||||
paid_03 = months['2026-03']['paid']
|
self.assertAlmostEqual(months['2026-02']['paid'], 750, places=2)
|
||||||
paid_04 = months['2026-04']['paid']
|
self.assertAlmostEqual(months['2026-03']['paid'], 350, places=2)
|
||||||
|
self.assertAlmostEqual(months['2026-04']['paid'], 150, places=2)
|
||||||
|
# No CZK lost
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
months['2026-02']['paid'] + months['2026-03']['paid'] + months['2026-04']['paid'],
|
||||||
|
amount, places=2,
|
||||||
|
)
|
||||||
|
|
||||||
# All months should be partial (underpaid)
|
def test_fill_first_across_two_transactions(self):
|
||||||
self.assertLess(paid_02, 750)
|
"""Prior txn fills 02 partially; later txn finishes 02 then spills to 03."""
|
||||||
self.assertLess(paid_03, 350)
|
members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})]
|
||||||
self.assertLess(paid_04, 750)
|
sorted_months = ['2026-02', '2026-03']
|
||||||
# Sum must equal the original payment (no CZK lost)
|
tx1 = _tx('Matyáš', '2026-02', 200)
|
||||||
self.assertAlmostEqual(paid_02 + paid_03 + paid_04, amount, places=2)
|
tx2 = _tx('Matyáš', '2026-02, 2026-03', 550)
|
||||||
# 02 and 04 have equal expected → equal allocation
|
|
||||||
self.assertAlmostEqual(paid_02, paid_04, places=2)
|
result = reconcile(members, sorted_months, [tx1, tx2])
|
||||||
|
months = result['members']['Matyáš']['months']
|
||||||
|
|
||||||
|
self.assertAlmostEqual(months['2026-02']['paid'], 500, places=2)
|
||||||
|
self.assertAlmostEqual(months['2026-03']['paid'], 250, places=2)
|
||||||
|
|
||||||
def test_single_month_unchanged(self):
|
def test_single_month_unchanged(self):
|
||||||
"""Single-month payment: full amount goes to that month (regression guard)."""
|
"""Single-month payment: full amount goes to that month (regression guard)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user