Skip to main content

Płatności i Historia Płatności

Przewodnik dla pracowników recepcji po obsłudze płatności, odczytywaniu historii portfela i oznaczaniu zaległości.

👤 Instrukcja dla pracownika (Recepcja / Administracja)

Moduł płatności umożliwia szybką weryfikację oraz rozliczanie zadłużenia klientów. Zapewnia on scentralizowany dostęp do wszystkich wpłat powiązanych z danym profilem (w tym z profilami dzieci/podopiecznych).

1. Przegląd historii płatności klienta

Weryfikacja zadłużenia oraz historii transakcji klienta możliwa jest z poziomu panelu administracyjnego: Ścieżka dla pracownika: Dashboard ➔ Uczestnicy ➔ Szukaj uczestnika ➔ Kliknij "Historia płatności"

Wyświetlone okno dialogowe zawiera:

  • Wykres miesięczny obrazujący przepływy finansowe.
  • Karty podsumowujące łączną kwotę wydatków oraz aktualne zadłużenie.
  • Kompleksową listę transakcji (opłaconych, zaległych oraz zwróconych). Lista transakcji może być filtrowana według miesiąca, statusu oraz zawężana do konkretnego podopiecznego.

2. Rejestracja płatności (Wpłaty w recepcji)

W przypadku dokonywania wpłaty w sposób stacjonarny (gotówka, terminal płatniczy): Ścieżka dla pracownika: Dashboard ➔ Uczestnicy ➔ Historia płatności ➔ Wybierz płatność ➔ Kliknij "Oznacz jako opłacone" ➔ Wybierz formę (Gotówka/Karta)

  • Zatwierdzenie formularza powoduje natychmiastową zmianę statusu na "Opłacona".
  • Jeżeli opłacone z góry zajęcia zostały anulowane przez administrację (np. z winy obiektu), w opcjach wybranej płatności dostępna jest funkcja Zwrotu do Wirtualnego Portfela klienta.

3. Płatności online Klienta (Płatność z Portfela i płatności mieszane)

Klienci regulują swoje zobowiązania logując się do Portalu Klienta. Ścieżka dla klienta: Portal Klienta ➔ Płatności ➔ Zaznacz pozycje na liście ➔ Kliknij "Zapłać"

System obsługuje płatności mieszane, umożliwiając częściowe pokrycie kosztów z salda Wirtualnego Portfela (np. zgromadzonego w wyniku zwrotów):

  • Podczas finalizacji transakcji, system pozwala na użycie dostępnych środków z portfela.
  • Moduł płatności automatycznie pobiera maksymalną dostępną kwotę z salda, a różnicę przekazuje do obsługi poprzez zintegrowaną bramkę (Przelewy24) lub do opłacenia stacjonarnego.
  • W przypadku anulowania takich zajęć w przyszłości, algorytm precyzyjnie rozdziela kwotę zwrotu, automatycznie księgując odpowiednią wartość z powrotem na saldo Wirtualnego Portfela. Wymaga to zerowej ingerencji manualnej ze strony personelu.

4. Saldo portfela na liście płatności

Nad listą płatności widoczna jest zielona plakietka z aktualnym saldem Wirtualnego Portfela.

Ścieżka: Portal Klienta ➔ Płatności ➔ plakietka „Portfel" w nagłówku

  • Plakietkę widzą klienci oraz instruktorzy biorący udział w zajęciach — instruktor ma własny portfel i rozlicza swoje zajęcia dokładnie tak samo jak klient.
  • Pracownicy recepcji i administracji nie widzą plakietki, bo na tej liście pracują na płatnościach klientów, a nie na własnym portfelu. Saldo portfela wybranego uczestnika sprawdzają w Dashboard ➔ Uczestnicy ➔ Historia płatności.

5. Rozliczanie kartami benefitowymi (Multisport, Medicover, PZU Sport, FitProfit)

Karty benefitowe obniżają kwotę do zapłaty o 15 zł za każde wejście. Można je zaznaczyć w pierwszym kroku okna płatności — zarówno w zakładce Płatności, jak i przy rozliczaniu rezerwacji z kalendarza.

Ścieżka dla pracownika: Dashboard ➔ Płatności ➔ Oznacz jako opłacone ➔ Płatność pojedyncza ➔ Wybierz liczbę kart ➔ Dalej ➔ Gotówka/Karta

  • Kwota widoczna w oknie płatności jest już pomniejszona o wejścia z kart — to ona trafia na paragon i to ją klient dopłaca (np. 70 zł minus 1× Multisport = 55 zł).
  • Na paragonie karta benefitowa jest wykazana jako rabat: cena przed rabatem, wartość rabatu i kwota do zapłaty.
  • Karty benefitowej nie łączy się z kodem rabatowym — zaznaczenie kodu czyści wybrane karty.
  • Wykorzystane wejścia raportowane są osobno w Finansach (widżet użycia kart benefitowych), niezależnie od przychodu gotówkowego.

6. Interpretacja statusów Płatności

  • Oczekująca (Pending) – transakcja wygenerowana w momencie rejestracji; termin jej zapłaty jeszcze nie upłynął.
  • Zaległa (Overdue) – transakcja nieopłacona w terminie. W zależności od konfiguracji lokalnej, status ten może skutkować blokadą na rejestrację kolejnych rezerwacji oraz automatycznym wysłaniem powiadomienia windykacyjnego SMS. Zmiana statusu na opłaconą (np. po uregulowaniu długu na recepcji) natychmiast znosi te blokady.
  • Zwrócona / Anulowana – ewidencja anulowanych rezerwacji oraz zrealizowanych zwrotów. System przechowuje historię tych operacji do celów weryfikacyjnych, jednak nie są one wliczane do bieżącego zadłużenia.

7. Kolejność opłacania miesięcy po stronie klienta

Klient w swoim panelu płatności rozlicza miesiące po kolei — dopóki najstarszy nieopłacony miesiąc nie zostanie uregulowany, przyciski zapłaty przy późniejszych miesiącach są zastąpione komunikatem z nazwą miesiąca, który blokuje płatność.

O tym, do którego miesiąca trafia płatność, decyduje pierwsza dostępna data: data zajęć, następnie termin płatności, a na końcu data wystawienia. Dzięki temu pozycje bez zajęć i bez terminu (np. obozy) zostają w miesiącu, w którym powstały, a nie „wędrują" do miesiąca bieżącego.

Płatność, której okno rezerwacyjne wygasło (nieopłacona blokada slotu), nie jest traktowana jako zaległość i nie blokuje kolejnych miesięcy.

8. Płatność online za rezerwację, której terminu nie udało się utrzymać

Rezerwacja online blokuje kort tylko na czas płatności (domyślnie 10 minut, przedłużane do 15 minut w chwili przejścia do Przelewów24). Jeżeli potwierdzenie z bramki dotrze po wygaśnięciu blokady, a slot zdążył w tym czasie zająć ktoś inny, kort nie zostaje odebrany nowej rezerwacji — pierwszeństwo ma ta, która faktycznie utrzymała termin.

System rozlicza taki przypadek automatycznie i nie wymaga działania recepcji:

  • wpłacona kwota wraca na Wirtualny Portfel klienta (płatność otrzymuje status Zwrócona),
  • rezerwacja-widmo zostaje anulowana, więc nie pojawia się w grafiku ani w historii jako aktywna,
  • klient dostaje powiadomienie o odwołaniu rezerwacji z podanym powodem — zamiast mylącego potwierdzenia „płatność zaksięgowana",
  • w historii gry (event log) widnieją wpisy game_cancelled oraz refund_to_wallet.

Wyjątek wymagający obsługi ręcznej: rezerwacja gościa bez konta (rezerwacja publiczna) nie ma portfela, z którego mógłby skorzystać. Wtedy klient dostaje SMS z informacją, że skontaktujemy się w sprawie zwrotu, a w logach Cloudflare pojawia się DOUBLE_BOOKING_MANUAL_REFUND z numerem płatności do rozliczenia.

9. Zajęcia cykliczne jako jedna pozycja (widok pracownika)

Zajęcia jednego cyklu w danym miesiącu to jeden wiersz, zbudowany tak samo jak pozycja, którą klient widzi w swojej zakładce płatności: całą serię rozlicza się razem, więc pracownik nie odhacza czterech osobnych linii.

Wiersz zostaje w kolumnach tabeli — każda dana stoi pod swoim nagłówkiem, tyle że opisuje cały miesiąc, a nie pojedyncze zajęcia:

KolumnaCo pokazuje pakiet
Uczestnikuczestnik, jego ID i plakietki, jak w każdym wierszu
Opisnazwę zajęć ze strzałką rozwijania, pod nią liczbę terminów; identyfikatory zajęć są w tooltipie
Kwotasumę miesiąca, a pod nią kwotę pozostałą do zapłaty, jeśli część jest już rozliczona
Data zajęćskrócony zakres terminów (07–28 sie 2026, przy przełomie miesiąca 30 lip – 06 sie 2026) i godzinę
Termin płatnościnajwcześniejszy termin z pakietu, na czerwono przy zaległości
Statusstatus pierwszego nierozliczonego terminu, a przy miesiącu opłaconym częściowo dopisek „2 z 4 opłacone". Rozliczony termin ma zielony badge z samą formą płatności (Gotówka, Karta), pełne brzmienie zostaje w tooltipie; kliknięcie badge'a zmienia formę płatności wszystkim rozliczonym terminom miesiąca naraz
Archiwumplakietkę tylko wtedy, gdy zarchiwizowane są wszystkie terminy
Karty benefitowekarty zsumowane po typie z całego miesiąca
Faktura / Paragonnumer dokumentu do kliknięcia; przy kilku dokumentach numer pierwszego i +N rozwijające listę
Płatnośćprzycisk Rozlicz obejmujący wszystkie nieopłacone terminy miesiąca

Checkbox zaznaczania zostaje na swoim miejscu w kolumnie, więc akcje masowe działają jak dla zwykłych wierszy — zaznaczenie pakietu obejmuje wszystkie jego płatności.

Kliknięcie strzałki rozwija listę terminów: data, status, kwota, faktura, paragon, Rozlicz i pełne menu akcji dla każdego z osobna. Nic z operacji na pojedynczych zajęciach nie znika, schodzi tylko o jeden klik głębiej.

Czego pakiet nie łączy:

  • dwóch miesięcy tej samej serii — sierpień i wrzesień to osobne wiersze, bo miesiąc jest jednostką rozliczenia,
  • dwóch uczestników zapisanych na te same zajęcia — każde dziecko ma własny wiersz,
  • pojedynczych zajęć — cykl z jednym terminem w miesiącu oraz rezerwacje kortu zostają zwykłymi wierszami tabeli.

Zwijanie jest zawsze włączone i nie ma nic wspólnego z przełącznikiem „Grupuj po miesiącu" — ten dalej tylko dzieli listę na akordeony miesięcy. Zwykła lista płatności też pokazuje cykl jako jedną pozycję, bo stronicowanie liczy wiersze tabeli, a nie płatności: strona zawsze niesie komplet zajęć swoich pakietów, więc kwota na wierszu jest pełna, a ten sam cykl nie pojawia się na dwóch stronach.

Widok mobilny pracownika pokazuje każde zajęcia osobno.

Klient, który dostał SMS z linkiem do płatności i wyszedł z Przelewów24 bez zapłacenia, może wrócić tym samym linkiem i spróbować jeszcze raz. Dotyczy to rezerwacji kortu, zajęć wakacyjnych, półkolonii i weekendów z tenisem.

Link przestaje działać dopiero wtedy, gdy jest ku temu powód, a strona mówi klientowi który:

Co widzi klientKiedyCo robi recepcja
Już opłaconepieniądze doszłynic — płatność jest w historii
Link wygasłminęło okno blokady miejsca (to samo, co trzyma kort / miejsce na zajęciach)zapis trzeba powtórzyć, system wyśle nowy link
Rezerwacja anulowanarezerwacja została odwołananic — płatność nie jest już należna
Link już wykorzystanyzabezpieczenie na wypadek, gdy płatność jest zaksięgowana, ale status jeszcze tego nie pokazujepoproś klienta o odświeżenie za chwilę i sprawdź historię płatności

Wcześniej wystarczyło kliknąć „Płacę", żeby link zgasł na zawsze: klient, który rozmyślił się przy wyborze banku albo któremu przerwała się sesja, dostawał „Link już wykorzystany" i nie miał jak dokończyć płatności bez telefonu do klubu.


🛠️ Dokumentacja techniczna

Szczegóły operacji na transakcjach (payments) dla programistów.

Architektura i rozliczanie Portfela (Wallet)

Model bazy przechowuje historię w tabelach payment oraz wallet_transaction. Mechanizm obsługi w API:

  1. Wpłaty częściowe: Podczas płatności (np. payMultipleWithWallet), endpoint iteruje przez listę płatności i alokuje dostępny walletBalance. Zmienia status zapłaconych w całości na paid_wallet, w części na status wirtualny z saldem początkowym w portfelu. Zwraca tablicę pendingPaymentIds, żeby Frontend mógł dynamicznie zostawić użytkownikowi do zapłaty (P24) kwoty resztowe.
  2. Automatyczny Refund: Funkcje takie jak refundPaymentsByRelatedId sumują użycie portfela zapytaniem: COALESCE((SELECT SUM(amount) FROM wallet_transaction WHERE related_payment_id = p.id AND transaction_type = 'debit'), 0) Umożliwia to odesłanie precyzyjnej wartości na saldo portfela (INSERT do wallet_transaction typu credit), z pominięciem części uregulowanych Przelewami24 (te wymagają manualnej lub APIowej inicjacji zwrotu na kartę bankową klienta poprzez providera).
  3. Transakcje o statuse refunded nigdy nie kasują relacji z Invoice (jeżeli wygenerowano fakturę czy paragon to zachowuje referencję do wglądu księgowego).

Widoczność salda portfela w nagłówku Płatności

components/wallet/WalletBalance.tsx renderuje plakietkę tylko dla ról z WALLET_OWNER_ROLES (CLIENT, INSTRUCTOR); dla ADMIN i BACKOFFICE komponent zwraca null. Strona /dashboard/payments pobiera saldo raz po stronie serwera (getUserWallet) i przekazuje je jako initialBalance, więc plakietka renderuje się od razu, a zapytanie klienckie tylko ją odświeża.

Instruktor trafia na tę stronę przez fallback w getIsRouteAllowed (lib/roles.ts): gdy route_access nie przyznaje dostępu roli INSTRUCTOR, a instruktor ma ustawione wantsToParticipateInClasses, uprawnienia sprawdzane są ponownie jak dla CLIENT. Ta sama zasada („instruktor uczestniczący = klient") obowiązuje w tabeli płatności, gdzie useIsEmployee() obejmuje wyłącznie ADMIN i BACKOFFICE.

Kwoty na dokumentach a karty benefitowe

Kolumna payment.amount zawsze trzyma kwotę faktycznie pobraną od klienta — odliczenia z kart benefitowych i z portfela są od niej odejmowane w momencie zastosowania, a original_amount przechowuje cenę sprzed pierwszej korekty (COALESCE(original_amount, amount)). Zapisują to obie ścieżki rozliczenia: bulkApplyBenefitWithCards (zakładka Płatności, zapisy na obozy i kursy) oraz settlePaymentInClub (rozliczenie rezerwacji z kalendarza).

Kolumna benefit_card_types to wyłącznie ewidencja kart faktycznie odliczonych ([{ type, amount, count }]) na potrzeby raportów i wydruku rabatu — nie jest drugim źródłem prawdy o kwocie. Karta, która się nie mieści (np. 15 zł przy płatności 10 zł), nie jest zapisywana ani nie obniża kwoty. Przy rozliczeniu bez wskazania kart wcześniejszy wpis jest zachowywany (COALESCE(?, benefit_card_types)), a nie kasowany.

W UI różnicę original_amount vs amount interpretuje hasManualAmountOverride (lib/utils/payment-amounts.ts) — plakietka „zmieniona kwota zajęć" pojawia się tylko wtedy, gdy luki nie tłumaczą karty benefitowe.

Pozycje paragonu/faktury wylicza czysta funkcja resolveDocumentAmounts (lib/utils/payment-amounts.ts), używana przez processPaymentStatusChange:

  • amountAfterDiscount = payment.amount (kwota pobrana),
  • amountBeforeDiscount = payment.amount + suma kart (cena odtworzona sprzed rabatu),
  • kody rabatowe nie modyfikują payment.amount, więc gdy istnieje wpis w discount_code_usage, jego amount_before / amount_after mają pierwszeństwo.

Odtwarzanie ceny przez dodanie odliczenia jest tu kluczowe: wcześniejsza wersja brała za bazę original_amount ?? amount i odejmowała karty po raz drugi, przez co paragon po 1× Multisport na zajęciach za 70 zł opiewał na 40 zł zamiast 55 zł. Każda nowa ścieżka rozliczeń musi trzymać ten sam kontrakt: zmniejszasz amount → zapisz original_amount; nigdy nie odejmuj kart benefitowych od kwoty, która już je uwzględnia.

Cachowanie i optymalizacja UI

Z uwagi na naturę App Router i RSC w Next.js:

  • Konteksty dialogów historii posiadają wymuszony brak cacha. Trasy REST API (odczyt historii płatności gracza i klienta z /api/clients/ oraz /api/players/) opatrzone są flagami: export const dynamic = 'force-dynamic'; export const revalidate = 0; oraz nagłówkami HTTP: Cache-Control: private, no-cache, no-store, must-revalidate.
  • Każdy fetch pod spodem przekazuje { cache: 'no-store' }. Eliminuje to błędy "staleness", gdy po opłaceniu zaległości w P24 klient nadal widział na froncie dług.

payment.related_ids — co znaczy który element

related_ids to tablica JSON, w której wyłącznie pierwszy element jest identyfikatorem zajęć. Wszystkie ścieżki tworzące płatność zapisują albo [gameId], albo [gameId, seriesId]:

Kto tworzy płatnośćFormatKiedy
createPaymentsForRecurringSeries (lib/actions/payment.ts)[gameId, seriesId]zapis klienta na całą serię (addPlayerToRecurringSeries, ścieżka zapisu regularnego)
enrollPlayerInGame, enrollInSkillAssessment, updateGame, handlePriceChangeForPayments, rezerwacje kortu[gameId]dopisanie uczestnika do pojedynczych zajęć z panelu, zajęcia próbne, wakacyjne, rezerwacje

Na produkcji (baza klient) daje to ok. 26 tys. płatności school w formacie [gameId] i ok. 6,8 tys. w formacie [gameId, seriesId], przy czym 26 tys. z tych jednoelementowych dotyczy zajęć należących do serii cyklicznej. Z tego wynikają dwie zasady:

  1. Nie wolno wnioskować o serii z related_ids[1]. Płatność dopisanego z panelu uczestnika serii nie ma tego elementu, więc dopasowanie po nim ją pomija. Identyfikator serii bierze się z zajęć, na które wskazuje related_ids[0]: LEFT JOIN game g ON g.id = CAST(json_extract(p.related_ids, '$[0]') AS INTEGER) i dalej g.recurring_series_id. Tak działają groupPaymentsIntoRows (lib/utils/payment-series.ts), PAYMENT_BUNDLE_KEY_SQL (lib/payments-bundle-page-sql.ts), findMonthlyPaymentViolation (lib/monthly-payment-guard.ts) i wyszukiwanie płatności serii źródłowej w lib/recurring-transfer.ts.
  2. Nie wolno przeszukiwać całej tablicy w poszukiwaniu ID zajęć. recurring_game_series.id i game.id to osobne sekwencje AUTOINCREMENT, więc numery się pokrywają — na produkcji każdy ze 70 identyfikatorów serii nazywa też realne zajęcia. EXISTS (SELECT 1 FROM json_each(related_ids) WHERE value = gameId) doklejał więc do zajęć nr N wszystkie płatności serii nr N: 6822 obcych płatności na 568 205 zł rozłożone na 70 zajęć, w rekordowym przypadku 239 pozycji na 19 717,50 zł w oknie szczegółów jednych zajęć. Dopasowanie musi brzmieć CAST(json_extract(related_ids, '$[0]') AS INTEGER) = gameId.

Tabela pomocnicza payment_related_game (migracja 0163) trzyma to samo powiązanie w formie indeksowanej dla kalendarza (getGamesByCity). Jej triggery też wpisywały każdy element related_ids, więc powielały ten sam błąd — migracja 0261 zawęża je do related_ids[0] i czyści wiersze wpisane wcześniej.

Wyjątkiem, celowo zawężonym do related_ids[1], są CLEAR_SERIES_PAYMENT_HOLD_SQL i EXTEND_SERIES_PAYMENT_HOLD_SQL (lib/series-enrollment-sql.ts). Zdejmują one jeden termin zapisu rozciągnięty na cały cykl, a taki termin zakłada wyłącznie addPlayerToRecurringSeries, które zawsze zapisuje oba identyfikatory. Płatność [gameId] na zajęciach tej samej serii to osobno kupione, pojedyncze miejsce z własnym terminem — rozszerzenie dopasowania na wszystkie zajęcia serii zdjęłoby termin także z niego.

Blokada „najpierw najwcześniejszy nieopłacony miesiąc"

Reguła jest wymuszana wyłącznie po stronie klienta (widok mobilny payments-list-view-user.tsx i desktopowy PaymentsTableAccordion.tsx), w dwóch miejscach naraz: UI podmienia przycisk na PaymentBlockedNotice, a initiateBulkPayment odrzuca zlecenie z toastem.

Oba miejsca muszą liczyć miesiąc tą samą funkcją, inaczej klient dostaje aktywny przycisk i odrzuconą płatność wskazującą na miesiąc, którego nie ma na liście. Dlatego klucz miesiąca ma jedno źródło — getPaymentMonthKey (lib/utils/payment-month.ts):

  • kolejność dat: game_start_timedue_datecreated_at (ta sama, którą stosuje getAllPaymentsGroupedByMonth na serwerze),
  • kubełkowanie w strefie Europe/Warsaw (toPolandMonthKey) — daty w bazie są w UTC z sufiksem Z, więc klucz liczony w strefie przeglądarki rozjeżdżał się z serwerowym dla zajęć o granicy miesiąca,
  • etykieta miesiąca jest formatowana z klucza (monthKeyToDate), nie z surowej daty płatności, więc nagłówek nie może pokazać innego miesiąca niż ten, według którego działa blokada.

Bramka porównuje płatności z nearestUnpaidMonthKey, czyli z tego samego wyliczenia, które decyduje o wyglądzie UI (miesiące, w których zostały wyłącznie wygasłe blokady, nie liczą się jako nieopłacone).

Ograniczenia wynikające z zakresu danych: lista klienta jest filtrowana po mieście (p.city) i pomija zarchiwizowane, więc blokada działa w obrębie wybranej lokalizacji, natomiast kwota zaległości i zawieszenie konta (getOwnerOverdueStats) liczone są bez tych filtrów.

Widok "Grupuj po miesiącach" (Admin) — agregacja w SQL i lazy loading

Widok grupowania płatności po miesiącach w panelu administracyjnym (/dashboard/payments?groupByMonth=true) działa w modelu dwuetapowym, aby nie ładować wszystkich płatności do pamięci Workera (limit 128 MB na izolat w Cloudflare Workers powodował błędy "Worker exceeded memory limit" i ucięte odpowiedzi RSC — "Connection closed." u klienta):

  1. Podsumowania miesięcygetMonthlyPaymentSummaries (lib/actions/payment.ts) wykonuje agregację po stronie D1 (GROUP BY strftime('%Y-%m', ...)), zwracając wyłącznie: liczbę płatności, sumę, kwotę nieopłaconą i walutę per miesiąc. Serwer nie materializuje pojedynczych wierszy płatności.
  2. Szczegóły miesiąca na żądanie — po rozwinięciu akordeonu (lub zaznaczeniu miesiąca do rozliczenia) klient woła server action getPaymentsForMonth(monthKey, filters), która pobiera płatności tylko tego jednego miesiąca. Wyniki są cachowane per miesiąc w stanie PaymentsTable i unieważniane po router.refresh() (zmiana monthlySummaries inkrementuje generację cache).

Filtry (miasto, ulica, gracz, status, metoda, kwoty) współdzielą jeden builder warunków SQL — buildPaymentFilterParts — używany przez getAllPayments i getMonthlyPaymentSummaries, więc podsumowania i szczegóły zawsze widzą ten sam zbiór danych. Widok klienta (nie-admin) nadal grupuje po stronie przeglądarki — dane klienta są ograniczone do jego graczy i pozostają małe.

Zwijanie zajęć cyklicznych w jeden wiersz tabeli

Łączenie jest w całości po stronie przeglądarki — getAllPayments z withDetails zwraca już recurring_series_id i metadane serii, więc nie trzeba dodatkowych zapytań ani zmian w agregacji miesięcy.

  • bundleRecurringPayments (lib/utils/payment-bundles.ts) grupuje płatności kluczem (player_id, recurring_series_id, miesiąc). Miesiąc pochodzi z getPaymentMonthKey, czyli tej samej reguły „data zajęć → termin płatności → data wystawienia", po której klient dzieli swoje akordeony. Pakiet mniejszy niż dwie płatności nie powstaje.
  • Wiersz zbiorczy to kopia pierwszej płatności pakietu z dodatkowym polem bundle (BundledPaymentRow). Dzięki temu id wiersza pozostaje realnym identyfikatorem płatności.
  • expandBundledPaymentIds rozwija zaznaczony wiersz z powrotem na wszystkie identyfikatory płatności. Wywołują je wyłącznie akcje masowe tabeli; ścieżki mobilne (handleMobileBulk*) idą prosto do openMarkAsPaidDialog / openBenefitDialog / openCustomDiscountDialog, bo lista mobilna nie jest zwinięta i identyfikator wiersza jest tam zwykłą płatnością.
  • Stronicowanie płaskiej listy idzie przez getPaymentsBundlePage, nie przez getAllPayments z limit/offset. Paginowanie po płatnościach rozcinało serię między stronami i wiersz ogłaszał „2 zajęcia · 140.30 PLN" dla miesiąca, w którym są cztery. Funkcja najpierw wybiera klucze pakietów (LIMIT/OFFSET po GROUP BY bundle_key), a dopiero potem dociąga wszystkie płatności tych pakietów — dlatego strona może zwrócić więcej płatności niż wynosi limit, a hasMore liczy się z limit + 1 klucza, nie z liczby wierszy.
  • Zapytania są w lib/payments-bundle-page-sql.ts (PAYMENT_BUNDLE_KEY_SQL, buildBundleKeysQuery, buildBundlePaymentIdsQuery), więc dają się testować na zwykłym SQLite. Klucz miesiąca liczy sqlPolandLocal, ta sama reguła co getPaymentMonthKey w przeglądarce — bez tego zajęcia o 23:00 UTC ostatniego dnia miesiąca trafiłyby na serwerze do innego pakietu niż w tabeli.
  • Lista identyfikatorów jedzie do getAllPayments razem z parametrami filtrów, więc jest dzielona na kawałki mieszczące się w limicie parametrów D1 i sortowana z powrotem w JS.
  • PaginatedTable przyjmuje renderRowDetail — dokłada pod wierszem pełnej szerokości wiersz szczegółów; null trzyma go zwiniętym, więc stanem rozwinięcia zarządza wołający.
  • Zmiana formy płatności z wiersza zbiorczego przechodzi po wszystkich terminach, które da się edytować (isPaymentMethodEditable), pomijając nierozliczone i podzielone. Opcja „mieszana" jest tam wyłączona (allowMixed={false}): jednego podziału gotówka/karta nie da się rozłożyć na cztery płatności w sposób, który klub rozliczy.
  • Statusy rozliczonych płatności mają w tabeli pracownika krótką formę (getShortStatusKeypaymentStatus.paidCashShort i pokrewne) i zielony wariant success na Badge. Widok klienta korzysta z tego samego koloru, ale zachowuje pełne nazwy.
  • Komórki kolumn mają gałąź dla pakietu: agregują to, co da się zagregować (kwota sumuje amount_after_discount ?? amount bez anulowanych, termin bierze najwcześniejszą datę, karty benefitowe sumują się po typie, archiwum wymaga kompletu), a ukrywają to, co dotyczy jednej płatności — edycję metody płatności i menu akcji pojedynczej pozycji. Obie te operacje żyją na rozwiniętej liście terminów.
  • Stan rozwinięcia i akcję „Rozlicz cały pakiet" komórki dostają przez PaymentBundleContext, a nie przez argumenty getColumns(). getColumns() zostaje bezargumentowe i memoizowane, więc CellAction nie jest przemontowywane przy każdym renderze (co zamykałoby otwarte dialogi w wierszach).

Dokumenty pakietu (collectDocuments) są odfiltrowane po invoice_id / receipt_id, a nie liczone po płatnościach: processPaymentStatusChange przy rozliczaniu grupy płatności tego samego gracza zapisuje ten sam identyfikator dokumentu na każdej z nich, więc miesiąc rozliczony jednym ruchem ma jeden numer faktury. Gdy dokumentów jest więcej, karta pokazuje pierwszy numer i +N rozwijające listę terminów.

formatBundleDateRange skraca zakres dat do tego, co odróżnia oba końce — w obrębie miesiąca zostaje sam dzień początkowy (07–28 sie 2026), na przełomie miesiąca oba miesiące, na przełomie roku pełne daty. bundleStatusSource wskazuje pierwszą nierozliczoną płatność pakietu, więc status karty pokazuje zaległość, gdy tylko dotyczy któregokolwiek terminu.

Widok mobilny (payments-list-view.tsx) nie jest zwijany.

Obsługa statusu Overdue i Pending

W skryptach filtrujących czy blokujących (cron przypomnień, blokady wejścia na nowe rezerwacje - hasPlayerOverduePayments) zdefiniowany jest SQL lookup jako: status IN ('pending', 'overdue'). Status overdue to stan stricte logiczno-biznesowy nakładany na pending, gdy upłynie due_date. System traktuje je równoważnie przy wyliczaniu obrotów ARR oraz długu klienta. Odrzucane dla zliczania blokad są natomiast statusy cancelled i refunded.

UI w tabelkach korzysta z jednej, scentralizowanej tablicy SETTLED_PAYMENT_STATUSES z pliku @/constants/data dla określenia czy wiersz malować na zielono (opłacono) czy czerwono (zalega). Zabezpiecza to aplikację przed bugami przy wdrażaniu kolejnych rodzajów płatności dzielonych.

Kolizja slotu przy spóźnionym potwierdzeniu płatności (DOUBLE_BOOKING_PREVENTED)

clearGameExpiresAt (lib/actions/game.ts) to jedyny punkt, przez który przechodzi każde potwierdzenie płatności powiązanej z grą — webhook P24, płatność portfelem, płatność dzielona i rozliczenie zbiorcze. Przed zdjęciem blokady sprawdza on, czy kort nadal jest wolny (GAME_OCCUPIES_SLOT_SQL + COURT_TIME_OVERLAP_SQL z lib/utils/court-conflict.ts).

Sprawdzenie dotyczy wyłącznie gier z niepustym game_expires_at. Sens kontroli jest wąski: nie wolno wskrzesić wygasłej blokady na slocie, który ktoś zdążył zająć. Gra bez blokady — zajęcia grupowe, wystąpienie serii cyklicznej — nie walczy o kort, tylko już ma swoje miejsce w grafiku, więc nakładka na tym samym korcie nie mówi nic o tej płatności. Ma to znaczenie praktyczne, bo wystąpienia serii cyklicznej są wstawiane bez guardu kolizji (lib/actions/game.ts, insert w createRecurringGameOccurrences — w odróżnieniu od createGameInternal, gdzie działa INSERT ... WHERE NOT EXISTS). Nakładki między zajęciami grupowymi są tam normalne i bez tego zawężenia każdy opłacony zapis na taką grupę generował fałszywy DOUBLE_BOOKING_PREVENTED (AP-878).

Gdy slot rezerwacji z blokadą jest już zajęty:

  1. blokada zostaje wygaszona (game_expires_at nie jest czyszczone) — to gwarancja, że double booking nie powstanie,
  2. logowany jest błąd DOUBLE_BOOKING_PREVENTED z gameId, paymentId i conflictingGameId,
  3. uruchamiana jest kompensata recoverDoubleBookedHold (lib/actions/double-booking-recovery.ts).

Kompensata wykonuje kolejno:

  • processRefundToWallet na adres payment.user_email (opis: wallet.refundForDoubleBooking) i updatePaymentStatus(paymentId, 'refunded'),
  • cancelGame na grze-widmie, a po jego powodzeniu cancelled_at / cancellation_reason na powiązanym wierszu booking — gdy anulowanie gry padnie, wiersz booking zostaje nietknięty (inaczej booking byłby anulowany przy wciąż aktywnej grze), a sprawa idzie do logu jako DOUBLE_BOOKING_CANCEL_FAILED,
  • powiadomienie reservation_cancelled_with_reason z powodem payments.doubleBookingSlotTaken (lub ...Manual, gdy zwrot na portfel nie był możliwy) — wysyłane tylko wtedy, gdy rezerwacja faktycznie została anulowana.

Warunki brzegowe, o które trzeba dbać przy zmianach:

  • Idempotencja i wyścigi. Przelewy24 potrafią dostarczyć notyfikację ponownie, a bulkUpdatePaymentStatus przy każdej dostawie ponownie ustawia status paid_online — dlatego sam status nie może być znacznikiem rozliczenia. Kompensata jest „zaklepywana" atomowym UPDATE payment SET payment_comment = ... WHERE ... payment_comment NOT LIKE '%[DOUBLE_BOOKING_REFUNDED]%' i sprawdzeniem meta.changes === 1. payment_comment jest jedynym polem, którego pisarze statusów nie ruszają, a pojedynczy UPDATE gwarantuje, że z dwóch równoległych dostaw dokładnie jedna rusza pieniądze. Znacznik jest przy okazji widoczny w panelu przy płatności — dla wariantu ręcznego ([DOUBLE_BOOKING_MANUAL_REFUND]) to główny sygnał dla obsługi. Gdy uznanie portfela się nie powiedzie, claim jest zwalniany, żeby ponowna próba mogła jeszcze oddać środki.
  • Nie liczymy uznań portfela. Wcześniejsza wersja uznawała za „już rozliczone" istnienie dowolnego wallet_transaction typu credit z tym related_payment_id. To fałszywie łapało częściowe zwroty z innych ścieżek (refundForPriceChange, zwrot za usunięcie z zajęć) i potrafiło pominąć właściwy zwrot, informując klienta, że pieniądze wróciły.
  • Brak zwrotu na portfel przy gościach. isPublicGuestEmail oraz client_id zawierający local oznaczają konto, do którego klient się nie zaloguje — wtedy zamiast zwrotu leci logError z DOUBLE_BOOKING_MANUAL_REFUND.
  • Zakaz fałszywego potwierdzenia. sendBookingPaymentSuccessNotifications (lib/notifications.ts) pomija płatności, których gra nie jest już aktywna (archived, cancelled_at, wygasłe game_expires_at). Bez tego filtra klient dostawałby jednocześnie „płatność zaksięgowana" i „rezerwacja odwołana".

Przelewy24 nie mają w tym projekcie zaimplementowanego zwrotu przez API (Przelewy24.refundTransaction rzuca wyjątkiem), dlatego portfel jest jedyną automatyczną ścieżką oddania środków.

used_at znaczy „pieniądze doszły", a nie „ktoś kliknął przycisk". Znacznik stawia wyłącznie webhook P24 po zweryfikowaniu transakcji:

  • markPaymentLinksUsedByPaymentIds (lib/actions/payment-link.ts) — jednym UPDATE ... WHERE payment_id IN (...) dla wszystkich płatności sesji,
  • markCampPaymentLinkUsedByPaymentId i markTennisCoursePaymentLinkUsedByPaymentId — obok oznaczenia zapisu jako opłaconego.

Trasy POST /api/public/*/initialize nie dotykają used_at. Rejestracja transakcji w Przelewach24 nie jest dowodem zapłaty, a stawianie znacznika w tym miejscu spalało link każdemu, kto wycofał się z bramki. Ponowne wejście w link tworzy po prostu nową sesję P24 (createPublicTransaction + bulkUpdatePaymentSessionIdPublic); porzucona sesja wygasa po stronie operatora.

Kolejność sprawdzeń w validatePaymentLink (i jej odpowiednikach dla półkolonii i weekendów) jest istotna dla komunikatu, który zobaczy klient:

  1. payment_status = 'expired' lub minięte payment_expires_atPłatność wygasła,
  2. minięte expires_at linku → Link wygasł,
  3. anulowana rezerwacja / zapis → Rezerwacja anulowana,
  4. status płatności inny niż pending / overdueJuż opłacone,
  5. used_atLink już wykorzystany.

Status stoi przed used_at, bo oba znaczą to samo zdarzenie — zaksięgowaną wpłatę — a „Już opłacone" jest odpowiedzią, której klient szuka. Punkt 5 zostaje jako zabezpieczenie na wypadek rozjazdu obu zapisów.

Ścieżki „wyślij link ponownie" (lib/actions/camp-registrations.ts, lib/actions/tennis-course-registrations.ts) szukają linku po used_at IS NULL AND expires_at > now, więc po tej zmianie trafiają w link, który klient już dostał, zamiast zakładać kolejny.

Czas życia linku to okno blokady miejsca (expiryMinutes = paymentTime przy tworzeniu), więc powrót do płatności jest możliwy dokładnie tak długo, jak długo miejsce jest trzymane.