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
- Zapisujesz klienta na zajęcia stałe tak jak dotychczas — w kalendarzu, przez edycję zajęć i listę uczestników.
- 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.
- 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.
- Gdy klient kliknie link i zaakceptuje regulamin, plakietka znika. Zapis jest potwierdzony.
- 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".
- 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.
- 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:
| Komunikat | Co zrobić |
|---|---|
| Uczestnik nie ma numeru telefonu | Uzupeł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 urodzenia | Uzupeł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ęcia | Regulamin w SMS-ie | Data urodzenia |
|---|---|---|
| Grupowe i „inne" — uczestnik do 18 lat | regulamin szkółki tenisowej | wymagana |
| Grupowe i „inne" — uczestnik od 18 lat | regulamin dla dorosłych i seniorów | wymagana |
| Wakacyjne | regulamin wakacyjnej szkółki | niewymagana |
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:
| Kolumna | Znaczenie |
|---|---|
token | 10 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_ids | JSON 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_version | Regulamin rozstrzygnięty w chwili zapisu, żeby urodziny między SMS-em a kliknięciem nie podmieniły umowy. |
status | pending → accepted | expired | cancelled. |
expires_at | Termin; 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.type | activity_types.category | Program | Dokument |
|---|---|---|---|
group | inna niż Wakacyjne | class | wg wieku uczestnika |
other | inna niż Wakacyjne | class | wg wieku uczestnika |
group lub other | Wakacyjne | vacation | vacation_statute, bez podziału wieku |
booking | — | null | procedura nie dotyczy |
individual | — | null | procedura 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_key | consent_log.consent_type |
|---|---|
vacation_statute | vacation_enrollment |
class_statute | school_enrollment |
adult_individual_statute | school_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:
| Program | Ekran | Klucze |
|---|---|---|
class | Zajęcia stałe | group_statute_acceptance_enabled, group_statute_acceptance_time |
vacation | Zajęcia wakacyjne | vacation_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:
| Program | Po wdrożeniu | Brak wiersza w app_settings |
|---|---|---|
class | włączony | traktowany jako włączony |
vacation | wyłączony | traktowany 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 statuteExpiresAt — markAttendeesPendingStatute() 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ści | Co się dzieje |
|---|---|
pending | cancelled — 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_mixed | pełna kwota na portfel klienta, refunded + funds_retained = 1 |
paid_split, paid_split_partial | nie ruszane automatycznie — trafia do logów jako sprawa do ręcznego podziału |
| cokolwiek innego | zgł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.
Zgoda w consent_log
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
| Plik | Rola |
|---|---|
migrations/0231_create_statute_acceptance_request.sql | tabela, ustawienia, wiersz crona |
lib/statute-acceptance.ts | rdzeń: guard, token, żądanie, akceptacja, wygaszanie. Bez importów Next i bez getCloudflareContext, bo ładuje go worker crona |
lib/statute-acceptance-view.ts | dane publicznej strony |
app/regulamin/[token]/ | strona akceptacji z odliczaniem |
app/api/public/statute-acceptance/route.ts | POST akceptacji + consent_log |
lib/actions/game.ts | wpięcie w updateGame i updateRecurringGames |
worker-crons.ts | rejestracja expire_statute_requests |
middleware.ts | /regulamin i /api/public/statute-acceptance jako trasy publiczne |
app/layout.tsx | statuteAcceptance 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.ts | rdzeń 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.ts | withLoggerDatabase — podpięcie bazy dla przebiegów poza żądaniem |
__tests__/lib/statute-acceptance.test.ts | 23 testy na SQLite w pamięci |
__tests__/lib/seat-payment-refund.test.ts | 17 testów rdzenia zwrotu (rollback, idempotencja, izolacja tenantów, płatności dzielone, portfel przy pending) |
__tests__/lib/logger-cron-binding.test.ts | 5 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.