Przejdź do głównej zawartości

Zapis na zajęcia stałe dla powracających klientów

Klient, którego już znamy, nie musi przechodzić przez zajęcia próbne po raz drugi. W kalendarzu uczestnika ma przycisk, który otwiera ten sam wybór stałych terminów co konwersja po zajęciach próbnych — bez linku z SMS-a, bez weryfikacji telefonu i bez etapu próbnego.

Przebieg 4-etapowego kreatora zapisu (Wizard)

Proces zapisu na zajęcia stałe dla powracających klientów jest ujednolicony z procesem zapisu na zajęcia próbne i składa się z 4 czytelnych etapów (EnrollmentWizardShell):

  1. Krok 1: Wybór uczestnika (participant):
    • Klient wybiera dziecko/uczestnika ze swojego konta, które posiada już przypisaną grupę/poziom przez trenera lub recepcję.
    • Jeśli żaden z uczestników na koncie nie ma przypisanej grupy, opcja w menu jest nieaktywna (disabled).
    • Jeśli co najmniej jeden uczestnik ma grupę, okno się otwiera i na liście wyświetlani są wyłącznie uczestnicy uprawnieni.
  2. Krok 2: Adres korespondencyjny (address):
    • Wprowadzany raz na klienta, automatycznie predefiniowany z profilu użytkownika (user_metadata).
    • Wyszukiwanie z rejestrów GUS / TERYT & PRG oraz kodów pocztowych dla wygody i bezbłędności.
  3. Krok 3: Wybór terminu (slots):
    • Wyświetlenie stałych terminów w przypisanych grupach z widocznymi odznakami poziomów, informacją o całym cyklu oraz możliwością zaznaczenia wielu grup (np. poniedziałek + czwartek).
    • Opcja zgłoszenia „Żaden termin mi nie pasuje" z notatką dla recepcji.
  4. Krok 4: Płatność (payment):
    • Podsumowanie wybranych serii i pierwszej opłaty miesięcznej, kod rabatowy, preferencje faktury (z NIP-em) oraz obowiązkowa akceptacja regulaminu i RODO z przekierowaniem do Przelewy24.

👤 Instrukcja dla recepcji i biura

Gdzie klient znajduje przycisk

Ścieżka: Zajęcia ➔ (uczestnik) — baner nad kalendarzem uczestnika.

Widok Zajęcia otwiera się domyślnie na zakładce Grupowe — to jest oferta, po którą przychodzi zdecydowana większość klientów, i to tam siedzi zapis na zajęcia stałe. Link z parametrem ?tab=group automatycznie inicjuje proces zapisu na stałe (startRegularEnrollment) i otwiera okno zapisu (lub wybór uczestnika). Parametry ?tab=individual oraz ?tab=vacation otwierają odpowiednio treningi indywidualne i tryb wakacyjny (tak wchodzą linki rejestracyjne z kampanii dla zalogowanych klientów).

Osobno działa ?tab=group-signup — parametr, z którym wchodzą linki rejestracyjne na zajęcia grupowe. Nie wybiera okna z góry: uczestnikom, którzy nie mają jeszcze za sobą zajęć próbnych, otwiera zapis na próbne, a tym, których trener przypisał już do grupy — zapis na stałe. Reklama zajęć grupowych trafia bowiem i do klientów przed próbnymi, a tym okno zapisu na stałe nie miałoby czego zaproponować (zapis na stałe wymaga przypisania do grupy).

Pomiędzy tymi dwiema grupami jest trzecia: klient po próbnych, ale przed przypisaniem do grupy. Dla niego parametr nie otwiera nic — zostaje na ekranie zajęć, gdzie baner (awaitingGroupAssignment) mówi wprost, że klub dobiera grupę. Otwarcie okna zapisu na stałe pokazałoby mu listę z każdym uczestnikiem wyszarzonym („uczestnik nie ma jeszcze grupy”), czyli dokładnie ślepy zaułek, którego ten parametr ma unikać.

Parametr dokłada wyłącznie link kampanijny, który wskazał usługę. Zwykła rejestracja konta (/auth/register, kreator bez tokenu) ląduje na gołym /dashboard/user-activities, więc świeżo zarejestrowany klient nie dostaje okna zapisu na twarz — sięga po nie sam, banerem lub przyciskiem nad kalendarzem.

Obok „Zapisz się na próbne zajęcia" pojawia się drugi przycisk „Zapisz się na zajęcia". Widać go tylko wtedy, gdy uczestnik ma już przypisany co najmniej jeden grupowy rodzaj zajęć. To przypisanie jest tym, z czego budowana jest lista terminów — bez niego nie ma czego zaproponować, więc przycisk się nie pokazuje.

Baner znika, gdy uczestnik ma już miejsce w zajęciach stałych — czyli figuruje jako opłacony uczestnik przyszłego terminu z serii cyklicznej (getPlayersWithSeriesEnrollment). Namawianie na zapis kogoś, kto co tydzień przychodzi na zajęcia, nic mu nie mówi. Sam zapis pozostaje otwarty przyciskiem nad kalendarzem — dobranie kolejnego terminu jest nadal możliwe, tyle że z inicjatywy klienta, a nie z banera.

Oba przyciski mają osobne warunki. Zaproszenie na próbne zależy od user_type uczestnika: próbne przysługują typom newParticipant, newGuardian i newParticipantAndGuardian, a uczestnik „powracający" dostaje wyłącznie zapis na zajęcia stałe. Dlatego baner pokazuje się także wtedy, gdy jedynym dostępnym krokiem jest zapis na zajęcia stałe.

Rekord uczestnika zakładany przy rejestracji konta dostaje user_type = 'newParticipant' — tak samo jak uczestnik dodany w kreatorze i uczestnik z landingu zapisów na próbne. Wcześniej to pole zostawało puste i próbne były dla takiego klienta zamknięte: picker wyszarzał go z powodem „Był(a) już na zajęciach próbnych", choć nigdy na próbnych nie był. Puste user_type (rekordy zakładane przez recepcję, konta sprzed migracji) nadal oznacza „próbne nie dotyczą" — ale picker mówi to teraz osobnym komunikatem, zamiast przypisywać klientowi historię, której nie ma.

Przypisanie robi trener przy zajęciach próbnych (patrz Poziom uczestnika) albo recepcja ręcznie w profilu uczestnika.

Co widzi klient

To samo okno co przy konwersji z próbnych: cykliczne zajęcia w przypisanej grupie, z trenerem, kortem, dniem i godziną, liczbą wolnych miejsc i kwotą pierwszej płatności.

Różnice wobec konwersji z próbnych:

Konwersja z próbnychPowracający klient
Wejścielink z SMS-a (/continue/{token})przycisk w kalendarzu
Logowaniesesja mintowana z tokenuklient jest już zalogowany
Zakres lokalizacjiadres, na którym odbyły się próbnewszystkie adresy w mieście klienta
Fakturatak, przed płatnościątak, przed płatnością
Akceptacja regulaminuwymagana przed płatnościąwymagana przed płatnością
Kod rabatowybraktak, przed płatnością

Zakres lokalizacji różni się celowo: przy próbnych wiadomo, gdzie klient był, a powracający klient dopiero wybiera, gdzie chce grać. Miasto bierzemy z ustawienia preferred_city na koncie klienta, a nie z żądania przeglądarki.

Zapis obejmuje cały cykl

Tak samo jak przy konwersji: klient nie wybiera pojedynczych zajęć, tylko wszystkie przyszłe wystąpienia wybranej serii, a płatność online obejmuje pierwszy pełny miesiąc. Miejsce musi być wolne we wszystkich zajęciach cyklu.

Reguła pełnego miesiąca (Minimum zajęć w pierwszej płatności) i blokada zapłaty za pojedyncze zajęcia działają identycznie — opis w Konwersji z zajęć próbnych.

Kod rabatowy, adres korespondencyjny i faktura

Przed przejściem do Przelewy24 klient może wpisać kod rabatowy, podać adres korespondencyjny oraz zaznaczyć, że chce fakturę (na osobę prywatną albo na firmę — z wyszukiwaniem danych po NIP).

Adres korespondencyjny (GUS / TERYT / PRG)

  • Wprowadzany raz na klienta: Adres korespondencyjny jest powiązany z kontem klienta (user / local_user). Po jednorazowym wypełnieniu podczas zapisu jest automatycznie zapamiętywany i podpowiadany przy kolejnych zapisach oraz widoczny w edycji profilu (/dashboard/user-profile).
  • Inteligentne autouzupełnianie: Pole wyszukiwania łączy się z oficjalnymi rejestrami państwowymi GUGiK / GUS TERYT & PRG oraz bazą kodów pocztowych:
    • Wpisanie fragmentu nazwy miejscowości lub ulicy zwraca oficjalne podpowiedzi z rejestru GUS.
    • Wpisanie kodu pocztowego (np. 45-809 lub 45809) automatycznie uzupełnia miejscowość i ulicę.
    • W razie niedostępności zewnętrznych rejestrów użytkownik zawsze może wprowadzić lub skorygować dane ręcznie.
  • Wymagane pola: Miejscowość, ulica, numer budynku oraz kod pocztowy w formacie XX-XXX (numer lokalu jest opcjonalny). Przycisk płatności pozostaje zablokowany do momentu uzupełnienia kompletnego adresu.

Kod jest w oknie tylko podglądany. Wiążąca weryfikacja dzieje się w /api/payments/initialize na realnych płatnościach, więc kod, który przestał być ważny między otwarciem okna a kliknięciem „Zapłać", zostanie odrzucony tam, a nie po cichu uznany.

Dane do faktury zapisują się na uczestniku (tabela invoice) przed zapisem na zajęcia — to je czyta webhook płatności, decydując między fakturą a paragonem. Okno mówi o tym wprost: pod polami faktury jest informacja, że dane obejmą wszystkich uczestników z konta i kolejne płatności, a zmienić je można w profilu.

NIP firmy jest sprawdzany sumą kontrolną (odrzucane są też numery z jednej powtórzonej cyfry, np. 9999999999) — w oknie i ponownie w /api/regular-enrollment (błąd invoice_tax_id_invalid). Bez tego zły numer przechodził do Fakturowni, która odmawiała wystawienia dokumentu już po opłaceniu zapisu — szczegóły w Wystawianiu faktur.

Akceptacja regulaminu

Na kroku płatności klient musi zaznaczyć akceptację regulaminu zajęć i RODO. Do tego czasu przycisk „Zapłać" jest nieaktywny.

To nie jest wyłącznie blokada w interfejsie: /api/regular-enrollment odrzuca zapis bez znacznika akceptacji błędem statute_required, więc żaden zapis na zajęcia stałe nie powstanie bez zgody. Akceptacja zapisuje się w historii pierwszych zajęć cyklu jako zdarzenie consent_accepted — po jednym wpisie na wybrany termin, widoczne tam, gdzie recepcja czyta historię zajęć.

„Żaden termin mi nie pasuje"

Działa jak przy konwersji: zgłoszenie trafia jako zadanie na tablicę (Dashboard ➔ Zadania) z kompletem kontekstu i notatką na leadzie. Etykieta źródła to zapis powracającego klienta na zajęcia stałe, więc recepcja od razu wie, że to nie jest klient po próbnych.

Kto może kogo zapisać

Opiekun zapisuje wyłącznie swoich uczestników. Recepcja i biuro (ADMIN, BACKOFFICE) mogą zapisać dowolnego uczestnika, bo prowadzą ten sam zapis przez telefon.

W obu przypadkach płatność trafia na opiekuna uczestnika, nie na osobę klikającą. Gdyby zapis wykonała sesja recepcji, opiekun nigdy nie zobaczyłby tej płatności ani nie mógłby jej opłacić.

Ustawienia group_* — panel administracyjny

Ścieżka: Panel administracyjny ➔ Ustawienia zajęć stałych.

Ekran działa dokładnie tak jak „Ustawienia zajęć próbnych" i „Ustawienia zajęć wakacyjnych": ustawienia zapisujesz per miasto (globalny selektor miasta u góry decyduje, którą lokalizację edytujesz).

UstawienieKlucz app_settingsDomyślnieCo robi
Włącz samodzielny zapisgroup_enrollment_enabledwłączoneWyłączenie chowa przycisk i baner u klienta; recepcja zapisuje dalej
Natychmiastowa płatność (online)group_immediate_payment_onlinewłączoneZapis online przekierowuje na Przelewy24 i rezerwuje miejsce na czas płatności
Natychmiastowa płatność (pracownik)group_immediate_payment_employeewyłączoneTo samo dla zapisu robionego przez recepcję
Czas na płatność online (min.)group_payment_time_online10Jak długo trzymane jest miejsce dla nieopłaconego zapisu online
Czas na płatność pracownik (min.)group_payment_time_employee10To samo dla zapisu przez pracownika
Czas na płatność (min.)group_payment_time10Wartość zapasowa, gdy powyższe pole jest puste
Maksymalna liczba terminówgroup_max_series7 (1–20)Ile stałych terminów klient może wybrać w jednym zapisie

Kilka rzeczy, które warto wiedzieć zanim coś przestawisz:

  • Wyłączenie zapisu nie blokuje recepcji. ADMIN i BACKOFFICE widzą terminy i zapisują dalej — przełącznik dotyczy wyłącznie samoobsługi klienta. Klient, który wejdzie ze starego linku ?tab=group, zobaczy komunikat, żeby skontaktować się z recepcją, a POST /api/regular-enrollment odrzuci taki zapis błędem enrollment_disabled. Uwaga na to, gdzie recepcja zapisuje: /dashboard/user-activities i /api/regular-enrollment są kompilowane wyłącznie do workera klienckiego, więc ten kreator otwiera się z domeny klienta (sesja pracownika działa tam normalnie), a nie z panelu administracyjnego.
  • Natychmiastowa płatność pracownika ma zęby. Zapis z wymaganą płatnością jest tymczasowy (draft + paymentExpiresAt) i po upływie okna wypada z listy obecności (filterValidAttendees). Przy zapisie przez telefon, gdzie nikt nie płaci od razu, włączenie group_immediate_payment_employee skasuje taki zapis po kilku minutach — dlatego domyślnie jest wyłączone.
  • O natychmiastowej płatności decydują wyłącznie ustawienia group_*. payment_due_type rodzaju zajęć mówi, kiedy wystawiany jest rachunek za dany miesiąc (na grupach day_of_month, czyli 7. dnia miesiąca), i nie ma wpływu na to, jak długo trzymane jest miejsce. Do sierpnia 2026 hold wymagał dodatkowo payment_due_type = immediate, czego żadna grupa nie ma — przez co nie powstawał nigdy.
  • Puste pole „czas na płatność" oznacza brak limitu — miejsce nie wygasa samo i trzeba je zwolnić ręcznie.
  • Maksymalna liczba terminów to realna decyzja biznesowa. Uczestnik trenujący dwa razy w tygodniu potrzebuje co najmniej 2; ustawienie 1 zmusi go do dwóch osobnych zapisów (i dwóch płatności). Każdy wybrany termin to osobny zapis na cały cykl, dlatego wartość jest twardo ograniczona do 20. Kreator wygasza zaznaczanie po osiągnięciu limitu, a żądanie z większą liczbą terminów jest odrzucane, nie przycinane — po cichu obcięty termin oznaczałby, że klient płaci za inny tydzień niż wybrał.

W przeciwieństwie do zajęć próbnych i wakacyjnych rodzina group_* nie ma ustawień „dni z wyprzedzeniem". Klient wybiera tu cotygodniowy termin z cyklu, a nie konkretną datę, więc nie ma okna widoczności, które można by otworzyć na X dni.

🛠 Dokumentacja techniczna

Kluczowe moduły

PlikRola
lib/recurring-series-options.tsLista stałych terminów + przypisane grupy — wspólna dla obu wejść
lib/game-availability-sql.tsDefinicja „zajętego miejsca" i strażnik seatAvailableSql używany w zapisie
lib/recurring-enrollment.tsZapis na cały cykl + oznaczenie płatności monthly_onlywspólne
lib/regular-enrollment.tsAutoryzacja uczestnika, opcje i zapis dla zalogowanego klienta
lib/group-activity-settings.tsTyp, wartości domyślne i rozstrzyganie czasu na płatność dla ustawień group_*
lib/actions/app-settings.tsgetGroupActivitySettings / updateGroupActivitySettings (cache per tenant)
components/forms/group-activity-settings/Formularz ustawień w panelu administracyjnym
lib/player-invoice-preferences.tsZapis preferencji faktury na uczestniku — wspólny z rejestracją na próbne
lib/enrollment-consent.tsZapis akceptacji regulaminu w historii zajęć — wspólny
app/api/regular-enrollment/route.tsoptions / enroll / no_match
lib/services/address-lookup.tsWyszukiwanie adresów w rejestrze GUGiK / GUS TERYT & PRG oraz kodów pocztowych
app/api/address-search/route.tsEndpoint API wyszukiwania i autouzupełniania adresów (?q=, ?postalCode=)
components/forms/address/correspondence-address-card.tsxReużywalny komponent adresu z autouzupełnianiem GUS i kodów pocztowych
components/forms/recurring-series/series-option-list.tsxKarty terminów — wspólne dla obu okien
components/forms/recurring-series/enrollment-payment-step.tsxKrok płatności (adres, rabat, faktura, regulamin) — wspólny dla obu okien
components/forms/regular-enrollment/Okno dwuetapowe: wybór terminu → płatność
.../user-activities/[playerId]/components/EnrollBannerWrapper.tsxPrzycisk w banerze nad kalendarzem

Migracje:

  • 0200 dodaje wiersz route_access dla /api/regular-enrollment.
  • 0215 dodaje kolumny adresu korespondencyjnego (addressStreet, addressBuildingNumber, addressApartmentNumber, addressPostalCode, addressCity) do tabeli user i local_user oraz uprawnienia do /api/address-search.
  • 0221 dodaje typ powiadomienia regular_enrollment_confirmed.
  • 0222 dodaje typ powiadomienia regular_enrollment_pending_payment.
  • 0226 zasiewa ustawienia group_* (kategoria „Zajęcia stałe") dla każdego miasta i każdego tenanta, plus wiersz ALL. INSERT OR IGNORE, więc nie nadpisuje wartości ustawionych przez admina i można ją puścić ponownie. Lista domyślnych wartości to tabela VALUES w CTE, a nie łańcuch UNION ALL — D1 ma niski limit liczby członów w compound SELECT i sześć z nich już go przekracza (too many terms in compound SELECT).

Kiedy klient dostaje potwierdzenie

enrollPlayerInSeries woła addPlayerToRecurringSeries z opcją deferConfirmationUntilPaid, więc na tej ścieżce nie leci player_added_to_recurring_series. Potwierdzenie regular_enrollment_confirmed (e-mail oraz SMS) wychodzi dopiero z webhooka Przelewy24 po zaksięgowaniu pierwszego miesiąca — szczegóły w Powiadomieniach systemowych.

Powód jest prosty: klient, który porzuci płatność, nie powinien dostać wiadomości mówiącej, że jest zapisany. Dodanie uczestnika przez recepcję nie przechodzi przez enrollPlayerInSeries i potwierdza tak jak wcześniej.

Żeby jednak nie zostawić klienta w ciszy, enrollPlayerInSeries wysyła od razu regular_enrollment_pending_payment — e-mail „zapis przyjęty, czekamy na płatność" z kwotą do zapłaty i, jeśli obowiązuje, godziną wygaśnięcia rezerwacji miejsca.

Co dzieje się dalej, zależy od ustawień group_* dla miasta kortu:

  • z włączoną natychmiastową płatnością (group_immediate_payment_online dla klienta, group_immediate_payment_employee dla recepcji) — addPlayerToRecurringSeries zapisuje uczestnika jako draft: true i ustawia payment_expires_at na okno z ustawień (group_payment_time_online, group_payment_time_employee, z zapasowym group_payment_time; puste = brak wygasania). Po tym czasie expirePendingPayments zwalnia miejsce i wysyła payment_expired. Domyślnie zapis recepcji nie tworzy draftu, ale klub może to włączyć.
  • z wyłączoną — płatność czeka w zakładce Płatności bez terminu wygaśnięcia i ten e-mail jest jedynym sygnałem, jaki klient dostaje.

Kwota po wygaśnięciu zapisu

Zapis obejmuje cały cykl, więc addPlayerToRecurringSeries zakłada po jednej płatności na każdy termin (38 terminów sezonu = 38 wierszy) i wszystkim nadaje ten sam payment_expires_at. Do bramki płatniczej idzie jednak tylko pierwszy miesiąc. Gdy zapis wygasa, unieważniane są wszystkie wiersze — ale komunikaty muszą mówić o tym, o co klient był proszony, a nie o wartości całego sezonu.

Tym zajmuje się lib/series-upfront-billing.ts. Zapis jest identyfikowany trójką uczestnik + seria + termin wygaśnięcia (wszystkie płatności jednego zapisu dzielą ten sam payment_expires_at, więc druga próba zapisu do tej samej grupy nie miesza się z pierwszą). Dla każdego takiego zapisu moduł doczytuje z bazy komplet jego płatności — nie opiera się na tym, co akurat wygasło w danej turze, bo jeden przebieg crona bierze maksymalnie 500 wierszy i potrafi przeciąć sezonowy zapis na pół. Na tym komplecie odtwarza okno pierwszego miesiąca tą samą funkcją, którą liczy je koszyk (selectFirstMonthOccurrences), i zwraca kwotę oraz liczbę terminów tej raty.

Regułę „minimalna liczba zajęć w pierwszym miesiącu" (trial_conversion_min_first_month_sessions) obie strony czytają dla miasta kortu: koszyk bierze je z kortu serii (selectFirstMonthPayments), a wygaszanie z payment.city, które resolvePaymentLocation zapisuje z tego samego kortu. Wcześniej koszyk czytał je dla miasta z sesji klienta — przy klubach z różnymi ustawieniami per miasto obie strony mogłyby wskazać inny miesiąc.

Korzystają z tego dwa miejsca:

  • SMS/push payment_expired — podaje kwotę i liczbę terminów pierwszego miesiąca (np. „412,50 PLN, liczba terminów: 5"), a nie sumę całego cyklu,
  • wpis w Historii aktywności — każde zdarzenie attendee_expired dostaje w params pola dueAmount i dueSessions, a scalony wpis pokazuje je w polu Do zapłaty było („412,50 zł za 5 terminów") zamiast sumować ceny wszystkich terminów. Nagłówek wpisu nadal mówi o wszystkich anulowanych terminach, więc obie liczby — zwolnione terminy i opłacane terminy — widać w jednym miejscu.

Płatności spoza serii (rezerwacja kortu, pojedyncze zajęcia) liczą się w całości, a gdy terminów nie da się odczytać, oba kanały wracają do zwykłej sumy — bezpieczniej podać kwotę zawyżoną niż zerową.

Serie bez opłaty potwierdzają natychmiast przy zapisie — nie powstaje żadna płatność, więc żaden webhook by tej wiadomości nie zwolnił.

Dlaczego to nie jest kopia konwersji

AP-649 miał odtworzyć flow z AP-646/647/648 dla innego wejścia. Zamiast kopiować, wspólna część została wyciągnięta: zapytanie o terminy, wyliczenie pierwszego miesiąca, oznaczanie płatności i karty w UI mają po jednej implementacji. lib/trial-followup-options.ts i lib/trial-followup-enroll.ts zostały z tym, co faktycznie dotyczy zaproszenia: tokenem, jego cyklem życia i mintowaniem sesji.

Konsekwencja praktyczna: zmiana zasad wyliczania pierwszego miesiąca albo tego, co znaczy „wolne miejsce", działa od razu na obu ścieżkach.

Zabezpieczenie przed podwójnym zapisem

Konwersja z próbnych ma token, który można „zająć" (completed_at). Tutaj takiego wiersza nie ma, więc rolę strażnika pełni sama lista: zapytanie o terminy odrzuca serie, w których uczestnik już jest (already_enrolled), a enrollInRegularSeries przelicza listę na nowo i wymaga, żeby wybrana seria wciąż na niej była. Drugie kliknięcie dostaje series_unavailable zamiast drugiego miesiąca płatności.

Dodatkowo addPlayerToRecurringSeries pomija zajęcia, w których uczestnik już figuruje, i wystawia płatności wyłącznie za faktycznie dopisane wystąpienia.

Blokada zapisu na pełną grupę

Celem jest powiedzieć klientowi „nie ma miejsc" zanim zobaczy bramkę płatności, i nie dopuścić do sytuacji, w której zapłacił za grupę, do której się nie zmieścił.

Co widzi klient. Pełna grupa nadal jest na liście terminów — z czerwoną plakietką „Brak wolnych miejsc", wyszarzona i nieklikalna. Nie znika, bo znikający kafelek nie niesie żadnej informacji: klient szukający wtorkowej grupy zobaczyłby po prostu listę bez niej i nie wiedziałby, czy grupa jest pełna, czy coś się zepsuło. Jeśli miejsca skończą się już po otwarciu okna, zapis jest odrzucany komunikatem „Wszystkie miejsca w tej grupie są już zajęte. Wybierz inny termin.", lista odświeża się sama, a klient wraca do kroku wyboru terminu z odznaczonym niedostępnym terminem.

Trzy warstwy.

  1. Lista (loadRecurringSeriesOptions) — liczy seats_left jako minimum po wszystkich przyszłych wystąpieniach serii. Zapis obejmuje cały cykl, więc jedne pełne zajęcia w środku blokują całość. Wynik jest przycięty do zera, żeby ręcznie przepełniona przez recepcję grupa nie pokazała ujemnej liczby miejsc.
  2. Weryfikacja przy wysłaniu (enrollInRegularSeries, enrollFromFollowup) — lista jest przeliczana od nowa i każdy wybrany termin musi mieć seats_left > 0. Obecność na liście nie jest dowodem dostępności; bramką jest liczba miejsc.
  3. Strażnik przy zapisie (addPlayerToRecurringSeries) — UPDATE każdego wystąpienia ma warunek seatAvailableSql, liczony przez SQLite na wierszu sprzed zapisu. Dwa żądania walczące o ostatnie miejsce nie mogą wygrać oba: przegrany dostaje zero zmienionych wierszy, wystąpienia dopisane wcześniej w tym cyklu są cofane, a funkcja rzuca series_fullprzed utworzeniem jakiejkolwiek płatności.

Warstwa 3 jest tą, która faktycznie gwarantuje limit; warstwy 1 i 2 istnieją po to, żeby klient dowiedział się wcześniej i bez wchodzenia w płatność.

Istniał obok tego POST /api/games/recurring-attendance — endpoint workera administracyjnego, który wołał addPlayerToRecurringSeries bez odroczenia potwierdzenia i bez kroku płatności. Usunięty w sierpniu 2026: nie miał wywołań w UI ani w historii repo, a w logach produkcyjnych liczba zapisów przez kreator pokrywała się co do jednego z liczbą wszystkich zapisów na cykl. Po włączeniu blokady miejsca na zajęcia grupowe zostawiłby uczestnika z wygasającym miejscem, którego nic nie potwierdza.

Skutek uboczny do świadomej akceptacji: addPlayerToRecurringSeries nie pozwala już przepełnić serii nikomu, także recepcji. W grafiku pojedynczych zajęć pracownik nadal może dodać osobę ponad limit (gameDialog.gameFull — „Limit osiągnięty. Dodajesz dodatkową osobę."), bo tamta ścieżka nie przechodzi przez tę funkcję. Gdyby recepcja potrzebowała przepełnić cały cykl, trzeba dołożyć jawny parametr pomijający strażnika — nie znosić go.

Definicja „zajętego miejsca" jest jedna dla całego systemu (attendeeHoldsSeatSql): uczestnik potwierdzony albo draft, którego okno płatności jeszcze nie minęło. Anulowani i przeterminowane rezerwacje nie liczą się do limitu — dzięki temu nieopłacony zapis zwalnia miejsce automatycznie.

Ta sama definicja obowiązuje w widokach dla pracownika. attendeeHoldsSeat (lib/attendee-seat.ts) to jej odpowiednik w TypeScripcie i pilnuje, żeby licznik miejsc w kalendarzu, dymek z listą uczestników i okno „Szczegóły zajęć" pokazywały ten sam skład grupy. Osoba, której płatność właśnie trwa, jest widoczna na liście z etykietą „Oczekuje płatności" — zajmuje miejsce, więc recepcja musi ją widzieć. Gdy okno płatności minie, znika ze wszystkich trzech miejsc naraz.

Co się dzieje, gdy klient nie zapłaci w terminie

Zwolnienie miejsca nie zależy od crona. expirePendingPayments przestawia tylko status płatności na expired i dopisuje wpis do historii zajęć — tablicy attendees nie rusza. Miejsce zwalnia się leniwie, w chwili minięcia paymentExpiresAt na samym wpisie uczestnika, więc działa nawet gdyby cron nie wstał. Nieświeży wpis znika fizycznie przy najbliższym zapisie do tych zajęć (filterValidAttendees).

Ten sam predykat rozstrzyga pytanie „czy ten uczestnik już tu jest?" — already_enrolled w liście terminów i getPlayersWithSeriesEnrollment. To jest istotne: gdyby liczenie miejsc i sprawdzanie zapisu rozjechały się, miejsce byłoby wolne dla wszystkich oprócz osoby, której właśnie przepadło — jedyna osoba pewnie zainteresowana ponownym zapisem widziałaby „Brak dostępnych terminów". Dopóki hold jest żywy, seria dalej znika z listy tej osoby i to jest właśnie zabezpieczenie przed podwójnym zapisem.

Uwaga konfiguracyjna: hold (draft + paymentExpiresAt) powstaje, gdy w ustawieniach group_* dla miasta kortu włączona jest natychmiastowa płatność dla danej ścieżki zapisu, zajęcia mają cenę większą od zera, a „Czas na płatność" nie jest pusty. Wyczyszczenie tego pola wyłącza wygasanie: płatność nie dostaje payment_expires_at, cron jej nie widzi (payment_expires_at IS NOT NULL), a miejsce jest zajęte bezterminowo.

Rezerwacja miejsca na czas płatności

addPlayerToRecurringSeries zapisuje uczestnika jako draft z paymentExpiresAt, gdy płatność jest wymagana natychmiast. Do AP-649 okno tej rezerwacji brało się z ustawień rezerwacji kortów (online_payment_time, z zapasowym 10) i dotyczyło wyłącznie roli CLIENT. Teraz decydują ustawienia group_* dla miasta kortu:

  • czy płatność jest natychmiastowa — group_immediate_payment_online dla klienta, group_immediate_payment_employee dla recepcji,
  • ile trwa rezerwacja — resolveGroupPaymentTime(): ustawienie szczegółowe, potem wspólne group_payment_time, a null w obu oznacza brak wygasania.

Oba warunki plus cena klienta (ta po uwzględnieniu indywidualnego cennika — ta sama, którą naliczane są płatności) składa resolveEnrollmentHold() w lib/group-activity-settings.ts — jedyne miejsce, które o holdzie decyduje.

Przedłużenie holdu przy wejściu do bramki

Gdy klient przechodzi do płatności, extendPaymentExpiration daje mu 15 minut od nowa. Przedłużany jest cały cykl, nie tylko to, co jest w koszyku:

  • EXTEND_SERIES_PAYMENT_HOLD_SQL — wszystkie oczekujące płatności tej serii,
  • EXTEND_SERIES_SEAT_HOLDS_SQL — wpisy uczestnika we wszystkich przyszłych instancjach,
  • EXTEND_SEAT_HOLD_SQL — pojedyncze miejsce, dla płatności bez serii (wakacyjne, próbne).

Powód jest ten sam co przy AP-884: do bramki trafia tylko pierwszy miesiąc, a hold obejmuje cały cykl. Przedłużenie samego koszyka zostawiłoby miesiące 2..N na pierwotnym terminie — wygasłyby, gdy klient jeszcze płaci, a potem nie miałby ich już jak odzyskać: CONFIRM_SERIES_SEATS_SQL odmawia potwierdzenia wygasłego holdu, a CLEAR_SERIES_PAYMENT_HOLD_SQL rusza wyłącznie wiersze pending.

Terminy przesuwają się tylko do przodu, a hold, który zdążył wygasnąć, nie jest wskrzeszany — mógł go w międzyczasie zająć ktoś inny.

Wartości domyślne (online włączone, 10 minut, pracownik wyłączony) odtwarzają dotychczasowe zachowanie, więc samo wdrożenie niczego nie zmienia. Klub, który miał ustawione własne online_payment_time dla kortów, dostanie od teraz 10 minut na zajęciach stałych, dopóki nie ustawi group_payment_time_online po swojemu.

Co się dzieje, gdy klient zapłaci (AP-884)

Zapis obejmuje cały cykl, ale do bramki płatności trafia tylko pierwszy miesiąc. Wszystkie wystąpienia dostają jednak ten sam hold (draft + paymentExpiresAt), a wszystkie wygenerowane płatności — ten sam payment_expires_at. Bez domknięcia zapisu po opłaceniu pierwszego miesiąca dalsze miesiące zostawały draftami: cron expirePendingPayments przestawiał je na expired kilka minut po tym, jak klient zapłacił, a zajęcia znikały z jego kalendarza i listy „Zajęcia" (zwalniając przy okazji miejsce w grupie).

Dlatego potwierdzenie płatności serii domyka cały zapis:

  • CONFIRM_SERIES_SEATS_SQL — zdejmuje draft (oraz paymentStartedAt / paymentExpiresAt) z wpisu uczestnika we wszystkich przyszłych, nieodwołanych wystąpieniach tej serii, ale tylko tam, gdzie hold jeszcze stoi — warunkiem jest attendeeHoldsSeatSql, ta sama definicja „ten uczestnik jest na zajęciach", której używa liczenie miejsc. Hold, który zdążył wygasnąć, zostaje nietknięty: miejsce wróciło do puli i mógł je już zająć ktoś inny, więc spóźnione rozliczenie (np. gotówka przyjęta po terminie) nie może po cichu przepełnić grupy na resztę cyklu. Ten sam warunek zostawia w spokoju wpis type = 'cancelled' tego uczestnika.
  • CLEAR_SERIES_PAYMENT_HOLD_SQL — kasuje payment_expires_at na pozostałych oczekujących płatnościach school tego uczestnika i tej serii. Zachowują własne terminy płatności i są ścigane jak każda inna zaległość, ale cron ich już nie unieważnia.

Oba zapytania siedzą w lib/series-enrollment-sql.ts, a wywołuje je confirmRecurringSeriesEnrollment z clearGameExpiresAt — czyli z tego samego wąskiego gardła, przez które przechodzi każde potwierdzenie płatności (online, portfel, gotówka w klubie, płatność dzielona, rozliczenie masowe). Seria jest rozpoznawana po related_ids[1], które zapisuje createPaymentsForRecurringSeries, więc płatność spoza zapisu na cykl nie jest ruszana. Operacja jest idempotentna.

Zachowanie przy braku płatności się nie zmienia: nic nie zostaje potwierdzone, hold wygasa i miejsca wracają do puli (patrz sekcja wyżej).

Odświeżanie listy „Zajęcia" po stronie klienta

Next trzyma payload dynamicznej trasy w cache routera przez staleTimes.dynamic (30 s), więc wejście na „Zajęcia" z innej zakładki potrafiło pokazać migawkę sprzed zapisu. useAutoRefresh (hooks/useAutoRefresh.ts) w UserActivities odświeża dane serwerowe w trzech momentach:

  • powrót na tę samą trasę w obrębie jednego dokumentu — pierwsze wejście zawsze idzie po dane do serwera (trasa dynamiczna z loading.tsx jest prefetchowana tylko jako szkielet), więc dopiero ponowne wejście może dostać payload z cache'u routera,
  • powrót strony na wierzchvisibilitychange, focus, przywrócenie z bfcache; nie częściej niż raz na 10 s,
  • upływ terminu płatności widocznego na stronie — wpis wygaszony przez serwer znika razem z banerem odliczania, zamiast czekać na ręczne F5. Terminy są odduplikowane (zapis na cykl daje jedną płatność na wystąpienie, wszystkie z tym samym terminem — bez tego jeden zapis oznaczałby kilkanaście równoczesnych odświeżeń). Jeśli po odświeżeniu serwer dalej zwraca ten sam termin — bo zegar urządzenia się spieszy — hook dopytuje jeszcze do czterech razy co 15 s, a potem czeka na powrót strony na wierzch.

Autoryzacja

Endpoint wymaga sesji (getAuthSession), waliduje CSRF i jest limitowany (20 żądań/min). Właściciel jest rozstrzygany zapytaniem po player.owner_email, a nie po tym, co przyszło w żądaniu — playerId z ciała żądania nie daje dostępu do cudzego uczestnika.

Miasto pochodzi z sesji (preferred_city), nie z żądania: lista terminów i ustawienia pierwszego miesiąca są per lokalizacja, więc przeglądarka nie może wskazać klubu, którego klient nie wybrał.