Skip to main content

Akceptacja regulaminu po zapisie przez recepcję

Zapis klienta na zajęcia stałe dokonany przy ladzie jest warunkowy. Miejsce jest trzymane, ale zapis staje się wiążący dopiero wtedy, gdy klient zaakceptuje regulamin z linku, który dostaje SMS-em. Brak akceptacji w wyznaczonym czasie oznacza automatyczne wypisanie i SMS z informacją o anulowaniu.


Część 1. Instrukcja dla recepcji

Jak to wygląda w praktyce

  1. Zapisujesz klienta na zajęcia stałe tak jak dotychczas — w kalendarzu, przez edycję zajęć i listę uczestników.
  2. Po zapisaniu system wysyła klientowi jeden SMS z linkiem do regulaminu. Klient dostaje osobny SMS na każdego uczestnika, ale tylko jeden na całość zapisu tego uczestnika — nawet jeśli zapisujesz go na kilka terminów.
  3. Na liście uczestników pojawia się przy nim żółta plakietka „czeka na regulamin". Miejsce jest w tym czasie zajęte i nikt inny go nie zajmie.
  4. Gdy klient kliknie link i zaakceptuje regulamin, plakietka znika. Zapis jest potwierdzony.
  5. Jeśli klient nie zaakceptuje w wyznaczonym czasie (domyślnie 30 minut), system wypisuje go z tych zajęć i wysyła mu SMS z wyjaśnieniem. W historii zajęć zostaje wpis „nie zaakceptował regulaminu w wyznaczonym czasie".
  6. Jeśli klient zdążył już zapłacić, pieniądze wracają na jego portfel — również gotówka wpłacona w recepcji i płatność kartą. Nie musisz tego księgować ręcznie. Klient widzi zwrot w historii portfela z opisem „Zwrot za anulowany zapis", a Ty w historii zajęć obok wypisania.
  7. Wyjątek: płatność podzielona między kilka osób. Tej system nie zwraca sam — nie wie, komu ile oddać, a przy racie odroczonej część pieniędzy w ogóle nie wpłynęła. Miejsce zwalnia się normalnie, ale zwrot trzeba rozdzielić ręcznie z ekranu płatności.

Co zrobić, gdy klient płaci przy kasie

Zapłata nie zastępuje akceptacji regulaminu — to dwie różne rzeczy i sama wpłata nie utrzyma miejsca. Jeśli klient stoi przy kasie i płaci, dopilnuj, żeby od razu kliknął link z SMS-a; inaczej za kwadrans system zdejmie go z zajęć, a pieniądze odłoży na jego portfel. Zapisując go ponownie, pokryjesz zapis z tego portfela — kwota nie przepada, ale robicie tę samą rzecz dwa razy.

Kiedy system nie pozwoli zapisać

Zapis zostanie odrzucony w całości, zanim cokolwiek się zmieni w kalendarzu, w dwóch przypadkach:

KomunikatCo zrobić
Uczestnik nie ma numeru telefonuUzupełnij numer w profilu klienta. Bez numeru nie ma jak wysłać linku, więc klient zostałby wypisany, nie wiedząc dlaczego.
Uczestnik nie ma daty urodzeniaUzupełnij datę w profilu. Od wieku zależy, który regulamin obowiązuje — poniżej 18 lat regulamin szkółki, od 18 lat regulamin dla dorosłych i seniorów. Nie dotyczy zajęć wakacyjnych, które mają jeden regulamin dla wszystkich.

Jeśli SMS nie uda się wysłać (np. awaria bramki), zapis zostaje cofnięty, a Ty dostajesz komunikat. Nie zostaje „wiszący" zapis, którego klient nie może potwierdzić.

Na jaki numer idzie SMS

Na numer uczestnika, jeżeli jest w jego profilu. Jeżeli go nie ma — na numer właściciela konta (opiekuna). W praktyce oznacza to, że SMS w sprawie dziecka trafia do rodzica.

Co, jeśli cofniesz zapis w międzyczasie

Usunięcie uczestnika z zajęć przed upływem terminu zamyka też oczekiwanie na regulamin. Klient nie dostanie SMS-a o anulowaniu za kwadrans.

Jakich zajęć dotyczy

Wszystkich zajęć grupowych, zajęć typu „inne" (Klub Seniora i każda kolejna kategoria, którą administrator doda pod tym typem) oraz zajęć wakacyjnych.

Który regulamin dostanie klient, zależy od rodzaju zajęć:

ZajęciaRegulamin w SMS-ieData urodzenia
Grupowe i „inne" — uczestnik do 18 latregulamin szkółki tenisowejwymagana
Grupowe i „inne" — uczestnik od 18 latregulamin dla dorosłych i seniorówwymagana
Wakacyjneregulamin wakacyjnej szkółkiniewymagana

Regulamin wakacyjny jest jeden dla dzieci i dorosłych, więc przy zapisie na zajęcia wakacyjne data urodzenia nie jest potrzebna i jej brak nie zablokuje zapisu. Numer telefonu jest wymagany zawsze — bez niego nie ma jak wysłać linku.

SMS nazywa rodzaj zajęć: „zapis na zajecia stale" albo „zapis na zajecia wakacyjne".

Zapis wakacyjny z kalendarza a kreator zapisu wakacyjnego

To dwie różne drogi i nie zbierają zgody dwa razy:

  • Kalendarz → edycja zajęć → lista uczestników — droga opisana w tym dokumencie. Zgoda przychodzi SMS-em po zapisie.
  • Profil uczestnika → kreator zapisu wakacyjnego — checkbox z regulaminem jest w samym kreatorze, zapis bez niego nie przechodzi. SMS nie jest wysyłany.

Czego funkcja nie obejmuje

  • obozów i kursów tenisowych — mają własne dokumenty wgrywane per sezon,
  • rezerwacji kortu i zajęć indywidualnych oraz odrabiania zajęć,
  • transferu między grupami — przeniesiony klient zachowuje wcześniejszą zgodę i nie dostaje nowego SMS-a,
  • zapisów, które klient robi sam przez panel — te zbierają zgodę w formularzu.

Ustawienia (panel administratora)

Każdy program ma własną parę ustawień, osobno dla każdego miasta:

  • Zajęcia stałe → Akceptacja regulaminu — dotyczy zajęć grupowych i typu „inne" (Klub Seniora itd.),
  • Zajęcia wakacyjne → Akceptacja regulaminu — dotyczy wyłącznie zajęć wakacyjnych. Domyślnie wyłączone — trzeba je włączyć w tym miejscu, osobno dla każdego miasta.

W obu sekcjach te same dwa pola:

  • Wymagaj akceptacji regulaminu po zapisie przez recepcję — wyłącznik całej procedury dla tego programu,
  • Czas na akceptację regulaminu (minuty) — domyślnie 30. Puste pole znaczy „bez limitu", co w praktyce wyłącza wypisywanie.

Wyłączenie procedury dla zajęć stałych nie wyłącza jej dla wakacyjnych i odwrotnie.


Część 2. Dokumentacja techniczna

Przepływ

Model danych

statute_acceptance_request (migracja 0231) — stan okna akceptacji, nie sama zgoda. Zgoda trafia do consent_log, który pozostaje źródłem prawdy dla RODO art. 7(1).

Kluczowe kolumny:

KolumnaZnaczenie
token10 znaków z alfabetu 31-symbolowego bez znaków mylonych na ekranie telefonu. Krótki, żeby cały link zmieścił się w jednym segmencie SMS.
game_idsJSON z dokładnie tymi zajęciami, które dodał ten zapis. To jest lista, którą cofa wygaszenie — nic, co uczestnik miał wcześniej.
document_key, document_versionRegulamin rozstrzygnięty w chwili zapisu, żeby urodziny między SMS-em a kliknięciem nie podmieniły umowy.
statuspendingaccepted | expired | cancelled.
expires_atTermin; accepted_at i resolved_at domykają wiersz.

Tabela celowo bez kluczy obcych — tak samo jak consent_log: żądanie przeżywa zajęcia, na które wskazuje.

Które typy aktywności są objęte

resolveStatuteProgramme(activityTypeKind, activityTypeCategory) w lib/statute-acceptance.ts — jedyna bramka procedury, pytana przez isStatuteAcceptanceEnabled() przed odczytem ustawień. Zwraca program, a nie tylko tak/nie, bo od programu zależy dokument:

activity_types.typeactivity_types.categoryProgramDokument
groupinna niż Wakacyjneclasswg wieku uczestnika
otherinna niż Wakacyjneclasswg wieku uczestnika
group lub otherWakacyjnevacationvacation_statute, bez podziału wieku
bookingnullprocedura nie dotyczy
individualnullprocedura nie dotyczy

Kategoria jest polem swobodnym, dlatego program class jest domyślny przez wykluczenie: nowa kategoria pod typem other (jak Klub seniora) jest objęta od razu, bez zmian w kodzie. Rozpoznawana jawnie jest tylko Wakacyjne (VACATION_ACTIVITY_CATEGORY z constants/data.ts).

Program idzie do loadStatuteCandidates(), które przy vacation ustawia documentKey na vacation_statute bez patrzenia na datę urodzenia — stąd brak blokady missing_date_of_birth dla wakacyjnych. findStatuteBlocks() zostaje bez zmian: kandydat z ustawionym dokumentem przechodzi test wieku, a blokada braku telefonu obowiązuje dalej.

Typ i kategoria idą przez assertStatuteAcceptancePossible(), requestStatuteAcceptanceForSeats() i requestStatuteAcceptance(); getGameById() czyta kategorię jako activity_type.category.

Zgoda zapisywana po akceptacji

statuteConsentTypeForDocument(document_key) odczytuje typ zgody z dokumentu zapisanego w żądaniu, a nie z bieżącej konfiguracji zajęć — zajęcia mogą się zmienić między SMS-em a kliknięciem:

document_keyconsent_log.consent_type
vacation_statutevacation_enrollment
class_statuteschool_enrollment
adult_individual_statuteschool_enrollment

Ten sam mapping obsługuje wpis consent_accepted w event_log zajęć, więc historia zajęć i profil klienta pokazują zgodę wakacyjną pod jej własną nazwą. Obok zawsze idzie druga zgoda: privacy_policy.

Strona /regulamin/[token] jest w całości sterowana document_key — link do dokumentu bierze się z CONSENT_DOCUMENTS[documentKey], a jego nazwę w tekście checkboxa wybiera mapa STATUTE_LINK_LABEL_KEYS w statute-acceptance-form.tsx (klucz nieznany → nazwa regulaminu szkółki).

Wyłącznik osobny dla każdego programu

Program decyduje, z którego ekranu ustawień czytane są wyłącznik i okno:

ProgramEkranKlucze
classZajęcia stałegroup_statute_acceptance_enabled, group_statute_acceptance_time
vacationZajęcia wakacyjnevacation_statute_acceptance_enabled, vacation_statute_acceptance_time

loadStatuteConfig() sprowadza obie pary do wspólnego kształtu StatuteAcceptanceConfig { enabled, minutes }, więc reszta modułu nie musi wiedzieć, z którego ekranu przyszła odpowiedź. resolveStatuteAcceptanceDeadline() przyjmuje dziś minuty, a nie obiekt ustawień — reguła zamiany okna na termin jest wspólna dla obu programów.

Wyłączenie jednego programu nie rusza drugiego.

Domyślne stany różnią się między programami i jest to celowe:

ProgramPo wdrożeniuBrak wiersza w app_settings
classwłączonytraktowany jako włączony
vacationwyłączonytraktowany jako wyłączony

Migracja 0258 zasiewa parę wakacyjną dla każdego miasta i najemcy mającego już jakiekolwiek ustawienia, ale z wyłącznikiem na 0 — klub włącza procedurę miasto po mieście, kiedy recepcja jest o niej uprzedzona. Okno jest zasiane mimo to, żeby pole miało sensowną wartość w chwili włączenia.

Migracja 0265 podnosi oba okna z 15 na 30 minut wszędzie tam, gdzie wciąż stoi zasiana wartość 15. Powód: 15 minut było krótsze niż sama płatność — klient wchodził w link, płacił w Przelewy24 i wracał po terminie. Okno ustawione ręcznie przez administratora migracja zostawia nietknięte.

Odczyt bez wiersza też zwraca „wyłączone", więc nowe miasto albo nowy najemca nie uruchomi procedury sam z siebie. To jedyne miejsce, w którym wakacyjne zachowują się inaczej niż zajęcia stałe, gdzie brak wiersza znaczy „włączone".

Współistnienie z oknem płatności wakacyjnej

Zajęcia wakacyjne z włączoną natychmiastową płatnością pracowniczą oznaczają nowego uczestnika jako draft z paymentExpiresAt. Akceptacja regulaminu dokłada obok tego statuteExpiresAtmarkAttendeesPendingStatute() kopiuje pozostałe pola wpisu, więc oba okna biegną niezależnie i wygaśnięcie któregokolwiek zwalnia miejsce.

Blokada miejsca

Uczestnik oczekujący na akceptację zostaje na liście game.attendees i ma ustawione pole statuteExpiresAt. To świadomie różni się od draft (używanego przy wstrzymanych płatnościach), który bywa odfiltrowywany z liczników: tutaj miejsce ma być naprawdę zarezerwowane przez całe okno. Pole czyta kalendarz (plakietka w AttendeeInfo) i cron.

Wygaszenie usuwa uczestnika z tablicy — jak removePlayerFromRecurringSeries — a ślad zostaje w event_log (statute_expired). Usuwane są wyłącznie wpisy z ustawioną flagą, więc równoległy zapis innym kanałem nie zostanie skasowany.

Wyznaczanie terminu

resolveStatuteAcceptanceDeadline() bierze group_statute_acceptance_time minut od chwili zapisu i skraca termin do startu pierwszych zajęć, jeżeli te zaczynają się wcześniej. Wartość pusta = brak terminu = procedura nie tworzy żądania.

Cron i granulacja

Job expire_statute_requests chodzi w wyzwalaczu */5 * * * * obok expire_payments, oba przez runIndependentJobs, żeby awaria jednego nie blokowała drugiego. Faktyczne wypisanie następuje 30–35 minut po zapisie — cron ma rozdzielczość 5 minut. Strona akceptacji sprawdza jednak expires_at, a nie moment przebiegu crona, więc klient, który kliknie w tej luce, i tak dostanie odmowę. Limit MAX_EXPIRIES_PER_RUN = 100 chroni budżet czasu workera przy zaległościach.

SMS

Wysyłane bezpośrednio przez sendSms z pominięciem sendNotificationToUser. Powód: to jedyna droga do utrzymania zapisu, a klient, który kiedyś wyłączył sobie powiadomienia SMS, zostałby po cichu wypisany z zajęć. Z tego samego powodu treści są stałymi w kodzie, a nie wierszami notification_texts — szablon, który straciłby {url}, potrafiłby wypisać wszystkich zapisanych.

Teksty są bez polskich znaków diakrytycznych: jedno „ą" przełącza wiadomość na UCS-2 i tnie segment ze 160 do 70 znaków.

Zwykłe powiadomienie player_added_to_recurring_series / player_added_to_game jest dla tych uczestników pomijane, żeby klient dostał jeden SMS o zapisie, a nie dwa sprzeczne.

Płatności

Zasada: uczestnik zdjęty z zajęć trzyma albo miejsce, albo pieniądze — nigdy ani jednego, ani drugiego. Zapłata nie jest akceptacją regulaminu, więc miejsce leci tak czy inaczej, ale rozliczenie idzie razem z nim.

Wygaszenie bierze płatności powiązane z tymi zajęciami (json_extract(related_ids,'$[0]')) i tym uczestnikiem, po czym:

Status płatnościCo się dzieje
pendingcancelled — a to, co klient zdążył dołożyć z portfela, wraca na portfel przed anulowaniem
paid, paid_cash, paid_card, paid_online, paid_wallet, paid_partial_wallet, paid_mixedpełna kwota na portfel klienta, refunded + funds_retained = 1
paid_split, paid_split_partialnie ruszane automatycznie — trafia do logów jako sprawa do ręcznego podziału
cokolwiek innegozgłoszone jako SEAT_REFUND_UNKNOWN_STATUS, nigdy po cichu pominięte

Zwrot idzie na portfel niezależnie od pierwotnej metody płatności — także za gotówkę wpłaconą w recepcji. Klub zatrzymuje wpłatę, klient zatrzymuje jej wartość; to ta sama zasada, którą stosuje wypisanie przez pracownika (refundPaymentsByRelatedIdForEmployeeRemoval).

Płatność podzielona nie jest zwracana automatycznie. createSplitPayment rozkłada jedną płatność na kilka wierszy linked_payment, które mogą wskazywać różnych płatników, a przy paid_split_partial część rat jest odroczona i klub nigdy ich nie pobrał. Uznanie całej kwoty nadrzędnej portfelowi właściciela miejsca zapłaciłoby więc niewłaściwej osobie i oddało pieniądze, których nikt nie wpłacił. Dlatego te dwa statusy idą do recepcji jako Released seat was paid by a split payment; the refund has to be divided by hand. Wypisanie przez pracownika pomija je z tego samego powodu.

Częściowa płatność portfelem zostaje przy pending. payPartialWithWallet obciąża portfel i zostawia płatność w pending z obniżoną kwotą. Samo anulowanie takiego wiersza oddałoby klubowi pieniądze z portfela za zajęcia, których klient już nie ma — więc uznanie idzie przed anulowaniem: przebieg, który padnie pomiędzy, naprawi się w kolejnym, bo wiersz wciąż jest pending, a strażnik idempotencji nie pozwoli uznać drugi raz.

Wpis w portfelu dostaje gotowy polski opis (Zwrot za anulowany zapis - <nazwa zajęć> (regulamin nie został zaakceptowany)), a nie klucz i18n: cron nie ma żądania, w którym mógłby go rozwinąć, a opis widzi klient w historii portfela. Na event_log zajęć ląduje wpis refund_to_wallet obok statute_expired.

Czego zwrot nie ruszy: płatności bez właściciela, bez kwoty albo takiej, której uznanie portfela się nie powiodło. Wtedy status wraca do poprzedniego (nigdy refunded bez pokrycia w portfelu), a sprawa idzie do logów jako Settled payments could not be refunded after a released sign-up.

Podwójne uznanie jest odcięte przez księgę portfela, nie przez status płatności: przed zwrotem sprawdzany jest istniejący wpis credit z tym related_payment_id i tym samym opisem, więc powtórny przebieg crona niczego nie dopłaca. Sam related_payment_id nie wystarcza — obniżenie ceny zajęć (handlePriceChangeForPayments) zwraca różnicę na portfel z tym samym identyfikatorem i zostawia płatność rozliczoną. Klucz oparty wyłącznie na płatności czytał taki wpis jako „już zwrócone" i oznaczał miejsce jako refunded, nie wysyłając ani złotówki — w dodatku raportując to jako sukces.

Rollback po nieudanym SMS-ie

releaseStatuteRequest(..., 'cancelled') to nie wygaśnięcie okna, tylko wycofanie zapisu, którego nie dało się potwierdzić SMS-em — recepcjonista wciąż stoi przy ladzie i za chwilę spróbuje ponownie. Dlatego ta ścieżka nie zwraca rozliczonych płatności: oddanie na portfel gotówki wziętej sekundę wcześniej kazałoby klubowi rozliczyć jeden zapis dwa razy. pending jest anulowane (wraz ze zwrotem tego, co poszło z portfela), a rozliczone płatności trafiają do logów do decyzji recepcji. Automatyczny zwrot ma tylko expired.

:::warning Historia Do 09/2026 wygaszenie zwalniało miejsce, ale rozliczonej płatności nie ruszało — zostawała jako opłacona bez miejsca, a ponowny zapis wystawiał tę samą kwotę drugi raz. Ostrzeżenie, które miało to wyłapywać, nigdy nie docierało do tabeli logs (patrz „Logi crona" niżej). :::

Logi crona

logInfo/logWarn/logError z modułów bibliotecznych piszą przez wspólny singleton loggera, który bazę bierze z getCloudflareContext(). W przebiegu scheduled nie ma żądania, więc każdy taki wpis był po cichu porzucany — job miał własny new Logger(env.DB), ale wszystko, co wywoływał, logowało w próżnię. Dlatego ostrzeżenia o pieniądzach bez miejsca nie widział nikt.

handleScheduled opakowuje teraz przebieg w withLoggerDatabase(env.DB, …): na czas joba singleton dostaje uchwyt do D1, a na końcu czeka na rozpoczęte zapisy (logger.flush()) i uchwyt oddaje. Podpinany jest wyłącznie uchwyt, nigdy tenantId — dzięki temu wspólny isolate nie przeniesie tożsamości jednego klubu na żądanie innego.

Zapisywana w route handlerze (app/api/public/statute-acceptance/route.ts), bo tylko tam istnieje kontekst żądania z adresem IP i user agentem klienta. Zapisywane są dwa wiersze: school_enrollment (z dokumentem rozstrzygniętym wg wieku) i privacy_policy, ze źródłem reception_link — nowa wartość ConsentSource, odróżniająca zgodę daną przez klienta na własnym telefonie od zapisu wykonanego przez pracownika (employee).

Pliki

PlikRola
migrations/0231_create_statute_acceptance_request.sqltabela, ustawienia, wiersz crona
lib/statute-acceptance.tsrdzeń: guard, token, żądanie, akceptacja, wygaszanie. Bez importów Next i bez getCloudflareContext, bo ładuje go worker crona
lib/statute-acceptance-view.tsdane publicznej strony
app/regulamin/[token]/strona akceptacji z odliczaniem
app/api/public/statute-acceptance/route.tsPOST akceptacji + consent_log
lib/actions/game.tswpięcie w updateGame i updateRecurringGames
worker-crons.tsrejestracja expire_statute_requests
middleware.ts/regulamin i /api/public/statute-acceptance jako trasy publiczne
app/layout.tsxstatuteAcceptance w PUBLIC_MESSAGE_NAMESPACES — bez tego komponenty klienckie renderują surowe klucze
scripts/route-variants.json/regulamin przypisane do wariantu CLIENT, inaczej build klienta wycina trasę
lib/seat-payment-refund.tsrdzeń rozliczenia zwalnianych miejsc: anulowanie pending, zwrot rozliczonych na portfel. Przyjmuje D1Database od wołającego, więc działa i w cronie, i w żądaniu
lib/logger.tswithLoggerDatabase — podpięcie bazy dla przebiegów poza żądaniem
__tests__/lib/statute-acceptance.test.ts23 testy na SQLite w pamięci
__tests__/lib/seat-payment-refund.test.ts17 testów rdzenia zwrotu (rollback, idempotencja, izolacja tenantów, płatności dzielone, portfel przy pending)
__tests__/lib/logger-cron-binding.test.ts5 testów podpięcia i domknięcia logów crona

Idempotencja i wyścigi

Akceptacja opiera się na warunkowym UPDATE ... WHERE status = 'pending' AND expires_at > ?. Podwójne kliknięcie albo dwie karty naraz kończą się jednym przejściem; przegrane wywołanie dostaje already_accepted lub expired. Wpis consent_accepted w event_log trafia tylko na najwcześniejsze zajęcia z żądania, żeby zgoda nie powtarzała się na trzydziestu wystąpieniach serii.