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 bez terminów anulowanych i zwróconych, 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 spośród nieopłaconych zajęć, 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.

Anulowany lub zwrócony termin nie liczy się do miesiąca: nie wchodzi do liczby zajęć, do sumy, do zakresu dat ani do tooltipa z identyfikatorami zajęć. Zostaje pod strzałką rozwijania, żeby pracownik widział, co się z nim stało — przeniesienie cyklu (refunded + funds_retained) i zwrot na portfel wyglądają tam tak samo jak zwykły zwrot. Bez tego miesiąc po przeniesieniu do innej serii pokazywał terminy z obu serii naraz: uczestniczka mająca pięć zajęć we wrześniu widniała jako osiem. Gdyby cały miesiąc był anulowany, wiersz wraca do surowej liczby terminów i kwoty 0,00, bo „0 zajęć" nie niesie żadnej informacji.

Termin płatności pakietu bierze się z nierozliczonych terminów. Wcześniej brał najwcześniejszą datę z całego miesiąca, więc opłacone pierwsze zajęcia potrafiły oznaczyć wiersz na czerwono „Przeterminowane", mimo że pozostałe terminy dopiero przed nami. Gdy wszystko jest rozliczone, kolumna wraca do najwcześniejszej daty miesiąca.

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.

11. Zmiana rodzaju zajęć i dopisanie uczestników w jednej edycji​

W oknie Edytuj aktywność można jednocześnie zmienić Rodzaj zajęć (np. z „Indywidualne (1os)" na „Grupa 2os.") i dodać graczy. Kwota każdego dopisanego uczestnika liczy się z cennika nowego rodzaju zajęć, tak samo jak kwoty uczestników zapisanych wcześniej. Dotyczy to zarówno pojedynczych zajęć, jak i zmiany całej serii.

Wcześniej nowi uczestnicy dostawali cenę rodzaju sprzed zmiany. Przykład z 13.09.2026: zajęcia założone jako „Indywidualne (1os)" (210 zł w niedzielę) przestawiono na „Grupa 2os." (120 zł) i dopisano dwie osoby. Obie dostały po 210 zł zamiast po 120 zł, a okno Rozliczenie zajęć pokazywało 210 zł, choć edycja zajęć wskazywała cennik 120 zł.

Jeśli wybrany rodzaj zajęć nie istnieje (np. został w międzyczasie usunięty), zapis kończy się błędem, a zajęcia zostają bez zmian — nie powstaje żadna płatność.

12. Zmiana ceny zajęć, które już się odbyły​

W oknie Edytuj aktywność można zmienić cenę niestandardową także po zajęciach i przy okazji usunąć uczestnika. System rozlicza to tak:

  1. Usuwany uczestnik nie jest przeliczany. Jego płatność zostaje anulowana albo zwrócona na portfel według pola „bez zwrotu na portfel", tak jak przy zwykłym usunięciu.
  2. Uczestnik, który zostaje i nic nie zapłacił — jego otwarta płatność dostaje nową kwotę.
  3. Uczestnik, który już zapłacił — dostaje płatność „Dopłata za zajęcia" na różnicę. Jej termin liczy się od chwili edycji, a nie od daty zajęć, bo to nowy dług: klient nie trafia od razu na listę zalegających.
  4. Rachunek podzielony w recepcji (np. 90 zł gotówką + 30 zł do zapłaty) liczy się jako jedna należność. Przy nowej cenie 210 zł otwarta część rośnie do 120 zł (210 − 90), a nie do 210 zł.

Jeśli czegokolwiek z tego nie da się zapisać, cała edycja kończy się błędem, a zajęcia, lista uczestników i płatności zostają bez zmian. Wtedy wystarczy poprawić problem i zapisać ponownie. Tak samo działa edycja całej serii — żaden termin nie zostaje zmieniony „w połowie".

Przykład z 13.09.2026 (Opole, Spokojna, Hala 2, „Grupa 2os."): po zajęciach podniesiono cenę do 210 zł i usunięto uczestnika, który zapłacił. Zapis zatrzymał się na dopłacie z terminem w przeszłości, ale zajęcia i jedna z płatności już się zmieniły. Klientka, która zostawała, dostała fałszywy dług 210 zł, a usunięty uczestnik zniknął z listy bez rozliczenia. Dane poprawiono ręcznie 14.09.2026.


🛠️ 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, planTotalPriceChange, 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.

Przeliczanie cen przy edycji zajęć​

Zmiana godziny, rodzaju zajęć albo ceny niestandardowej w updateGame i updateRecurringGames przelicza płatności zajęć przez planGameRepricing → planTotalPriceChange (szczegóły niżej: Przeliczenie ceny jako jedna paczka). Otwarta płatność dostaje nową kwotę, a opłacona różnicę zwracaną na portfel albo płatność „Dopłata za zajęcia”. Do 09.2026 oba bloki wybierały płatności przez json_each(related_ids), więc edycja zajęć nr N przeliczałaby też płatności serii nr N należące do innych zajęć i innych klientów.

Teraz płatności do przeliczenia wybiera się przez indeks payment_related_game (tenant_id, game_id), z dodatkowym warunkiem CAST(json_extract(p.related_ids, '$[0]') AS INTEGER) = gameId. Plan zapytania na produkcji: SEARCH prg USING INDEX idx_prg_tenant_game_player + SEARCH p USING INTEGER PRIMARY KEY — ok. 0,01 ms zamiast ok. 18 ms dla przeszukania wszystkich płatności school (40 tys. wierszy). Przy edycji serii zapytanie leci raz na każde zajęcia, więc różnica mnoży się przez liczbę terminów.

Audyt produkcji z 13.09.2026 (tylko odczyt): spośród 898 zdarzeń game_updated zmieniających godzinę, rodzaj lub cenę tylko 8 dotyczyło zajęć o numerze równym numerowi serii (gry 2–439, edytowane w 08–10.2025). Płatności tych serii powstały dopiero od 17.08.2026, więc żadna nie została przeliczona. Potwierdzają to logi ówczesnego handlePriceChangeForPayments z okresu retencji (od 15.06.2026, 177 wpisów, zero trafień w obcą płatność). Błąd był ukryty i ujawniłby się przy edycji starych zajęć. Test regresji: lib/actions/game.repriceRelatedIdsFirstSlot.test.ts.

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.

Rodzaj zajęć przy edycji (updateGame, updateRecurringGames)​

Obie akcje ustalają na początku, przed jakimkolwiek zapisem, efektywny rodzaj zajęć: przy zmianie activity_type_id wczytują nowy rodzaj (loadActivityTypeForUpdate: zapytanie z tenant_id, parsowanie pricing_rules; w serii raz na każdy wskazany rodzaj), w przeciwnym razie biorą currentGame.activity_type. Z tego jednego obiektu korzystają:

  • przeliczenie istniejących płatności (planGameRepricing),
  • płatności nowych uczestników (applyClientPricingOverride → createSystemPaymentForGameParticipation),
  • blokada zaległości (assertEnrollmentAllowedForEmployee) i akceptacja regulaminu (assertStatuteAcceptancePossible, isStatuteAcceptanceEnabled, requestStatuteAcceptanceForSeats),
  • rozpoznanie zajęć wakacyjnych i treść powiadomień.

Przeliczenie wybiera płatności przed dopisaniem nowych uczestników, więc płatności nowych osób nigdy przez nie nie przechodzą. Gdy ścieżka dopisania brała currentGame.activity_type, zmiana rodzaju w tej samej edycji zostawiała im starą cenę. Na produkcji dotknęło to gier 10599 (płatności 42621, 42622) oraz 10426–10428 (39807–39809); dane poprawiono ręcznie 13.09.2026. Test regresji: lib/actions/game.updateActivityTypeWithAttendees.test.ts.

Przeliczenie ceny jako jedna paczka (updateGame, updateRecurringGames)​

Incydent 13.09.2026, 14:54, gra 10599. Pracownik zapisał edycję z custom_price 210 i usuniętym uczestnikiem 278 (skipWalletRefundPlayerIds: [278]). Logi logs 836715–836718 pokazują kolejność zdarzeń:

  1. UPDATE game (nowa lista uczestników, custom_price 210) był już zatwierdzony,
  2. handlePriceChangeForPayments zmienił płatność 42621 gracza 292 z 30 na 210 zł. Była to otwarta część rachunku podzielonego z 42637 (90 zł paid_cash), więc klient miałby zapłacić 300 zł za zajęcia za 210 zł,
  3. dla opłaconej 42622 usuwanego gracza 278 próbował createPayment na 90 zł z dueDate 2026-09-12T07:00Z (days_before_event = 1 od zajęć sprzed kilku godzin). Walidacja z PR #1430 odrzuciła termin w przeszłości, updateGame rzucił „Failed to update game", a obsługa usuniętego uczestnika w ogóle się nie wykonała.

Z tego wynikają trzy błędy, poprawione razem:

BłądPoprawka
Zapis gry i płatności leciały osobnymi run(), a walidacja dopłaty dopiero w trakcieNajpierw plan (odczyty i wszystkie walidacje), potem jeden db.batch() z UPDATE game i wszystkimi zmianami płatności. Błąd planu nie zapisuje niczego, błąd batcha wycofuje całość (w shimie SQLite BEGIN IMMEDIATE … ROLLBACK, w D1 batch też jest atomowy)
Przeliczenie obejmowało uczestników usuwanych w tej samej edycjileavingAttendeeIds wyklucza ich płatności; rozlicza je wyłącznie refundPaymentsByRelatedIdForEmployeeRemoval / refundPaymentsByRelatedId
Każdy wiersz płatności porównywany z pełną ceną; wiersze cancelled/refunded/expired też dostawały nową kwotęPłatności grupowane per uczestnik. Dla każdego planTotalPriceChange liczy nową cenę jako sumę: odejmuje opłacone części, jedna otwarta płatność niesie resztę, a pozostałe otwarte są anulowane. Wiersze unieważnione są pomijane — to ten sam algorytm co przy rezerwacjach kortu

Termin dopłaty. Walidacja z PR #1430 (assertDueDateIsAcceptable) ma dwie ścieżki. Termin odziedziczony (datesInheritedFromPaymentId) omija walidację, bo przy podziale rachunku to nadal ten sam dług. Termin ustawiany musi być w przyszłości. Dopłata za podniesioną cenę to nowy dług, więc dziedziczenie nie pasuje: przeniesiony termin 12.09 od razu oznaczałby zaległość i blokadę zapisów (findEnrollmentDebtBlocks porównuje date(due_date) < date('now')). Dlatego dueDateForNewDebt (lib/payment-due-date.ts) bierze termin z harmonogramu rodzaju zajęć, jeśli jest późniejszy niż teraz + 60 s, a w przeciwnym razie teraz + 60 s — to samo minimum co przy payment_due_type = 'immediate' i przy rezerwacjach. Otwarta płatność, która dostaje nową kwotę, zachowuje termin z harmonogramu, bo to wciąż ta sama należność.

API w lib/actions/payment.ts:

  • planTotalPriceChange(payments, newTotal, options, now?) — nic nie zapisuje. Zwraca statements (D1PreparedStatement[]), walletRefunds i summary. Opcja cancelOpenRowsCoveredBySettled (zajęcia) anuluje otwarte części, gdy opłacone już pokrywają nową cenę. Rezerwacje jej nie włączają, bo settleHoldOnReservationEditedToFree potrzebuje otwartego wiersza z kwotą 0.
  • commitPriceChangePlans(plans, leadingStatements?, context?) — jeden db.batch([...leadingStatements, ...plan.statements]), potem zwroty na portfel i log „Payments re-priced against what is already settled".
  • preparePaymentInsert — wydzielone z createPayment: te same walidacje i resolvePaymentLocation, ale zwraca przygotowany INSERT. createPayment to teraz preparePaymentInsert + run().
  • applyReservationTotalPriceChange = planTotalPriceChange + commitPriceChangePlans, więc rezerwacje kortu też zapisują się atomowo i też nie wywracają się na dopłacie do minionej rezerwacji.

W serii updateRecurringGames najpierw planuje przeliczenie wszystkich terminów, a potem jednym batchem zapisuje UPDATE game wszystkich terminów i zmiany płatności. Pętla po terminach robi już tylko usunięcia, dopisania i event_log (czytany świeżo, żeby nie nadpisać wpisów refund_to_wallet).

Co nadal nie jest w paczce. Zwroty na portfel przy obniżce ceny oraz rozliczenie usuwanych i dopisywanych uczestników działają po zatwierdzeniu paczki, bo korzystają z addWalletTransaction, powiadomień i SMS-ów. Ich błędy są łapane i logowane per uczestnik (Failed to refund payment for player …), a edycja się nie wywraca. Przegląd logów prod (16.06–14.09.2026) nie znalazł takich wpisów.

Przegląd produkcji 14.09.2026 (tylko odczyt, tabela logs od 16.06.2026). Jedyny wpis context='updateGame' AND message='Failed to update game' to incydent z gry 10599 (log 836718, płatności 42621 i 42622, nieudana dopłata dla gracza 278). updateRecurringGames ma tylko odrzucenia COURT_SCHEDULE_OVERLAP (gry 3480–3483 i 543–546, 22.06.2026), które powstają przed zapisem. Innych półzapisanych edycji nie ma.

Testy regresji: lib/actions/game.pastClassRepricing.test.ts (minione zajęcia, opłacony i nieopłacony uczestnik, rachunek podzielony, brak zmian po błędzie — pojedyncze zajęcia i seria), lib/payment-due-date.test.ts.

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_time → due_date → created_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ęcy — getMonthlyPaymentSummaries (lib/actions/payment.ts) wykonuje agregację po stronie bazy (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 zapytania (100, odziedziczony po 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ę (getShortStatusKey → paymentStatus.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 i zwróconych, termin bierze najwcześniejszą datę spośród nieopłaconych, 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_at → Pł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 / overdue → Już opłacone,
  5. used_at → Link 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.