Rezerwacje kortów – instrukcja dla recepcji
Krótki przewodnik po codziennej obsłudze rezerwacji kortów: tworzenie, edycja, rozliczanie, odwoływanie i szukanie rezerwacji.
👤 Instrukcja dla pracownika
Gdzie pracujesz
Podstawowym narzędziem jest Kalendarz (menu boczne → Kalendarz, nagłówek strony „Harmonogram”). Widzisz w nim siatkę: kolumny to korty, wiersze to godziny.
- Miasto/lokalizację wybierasz w selektorze na górze panelu – filtruje on całą aplikację, nie tylko kalendarz.
- Widok przełączasz między dniem, tygodniem i miesiącem paskiem nad siatką.
- Zielone/puste pola to terminy wolne, kafelki to zajęte terminy.
Tworzenie rezerwacji
- Kliknij wolne pole w siatce, na korcie i o godzinie, o którą prosi klient.
- Otworzy się okno z dwiema zakładkami:
- Rezerwacja – zwykły wynajem kortu (domyślna, tej używasz najczęściej),
- Aktywność – zajęcia z instruktorem, treningi, zajęcia grupowe.
- Uzupełnij formularz:
- Kort – możesz zmienić, jeśli kliknąłeś nie w ten, co trzeba.
- Czas rezerwacji – godzina startu i końca. Długość zmienia się skokowo, zgodnie z ustawieniami klubu (np. co 30 min), i nie wyjdzie poza godziny otwarcia kortu.
- Cena – wyliczana automatycznie z cennika (uwzględnia reguły godzinowe i indywidualne zniżki klienta).
- Cena niestandardowa – wpisz, tylko jeśli chcesz nadpisać cennik.
- Notatki – widoczne wyłącznie dla pracowników, nie dla klienta.
- Płatność – dwie opcje opisane niżej („Płatność przy tworzeniu rezerwacji").
- Uczestnicy → Dodaj gracza – zacznij wpisywać imię, nazwisko lub telefon. Jeśli klienta nie ma w bazie, z tej samej listy założysz nowego (może być wymagany numer telefonu).
- Kliknij Utwórz rezerwację.
:::tip Bez powiadomienia Strzałka ▲ obok przycisku daje opcję Dodaj aktywność bez powiadomienia – rezerwacja powstanie, ale klient nie dostanie potwierdzenia (mail/SMS/push). Używaj, gdy poprawiasz coś „na już” i nie chcesz zasypywać klienta wiadomościami. :::
Bez uczestnika nie utworzysz rezerwacji – przycisk pozostanie nieaktywny do momentu dodania co najmniej jednej osoby.
Jeśli klub ma włączoną natychmiastową płatność, zobaczysz żółte ostrzeżenie: rezerwacja wygaśnie, jeśli nie zostanie opłacona w podanym czasie (np. 10 minut).
:::tip Rezerwacja za 0 zł Gdy wpiszesz cenę 0 (rezerwacja gratisowa), ostrzeżenie znika. Nie ma czego opłacić, więc kort nie jest blokowany na 10 minut, klient nie dostaje SMS-a z linkiem do płatności, a rezerwacja jest potwierdzona od razu – tak samo jak w klubie bez natychmiastowej płatności. :::
Płatność przy tworzeniu rezerwacji
W formularzu (jednorazowym i cyklicznym) masz sekcję Płatność. Decydujesz w niej o tej jednej rezerwacji – ustawienia klubu zostają nietknięte.
- Wymagaj płatności natychmiastowej – kort jest blokowany do czasu opłacenia i przepada, jeśli klient nie zapłaci w wyznaczonym czasie (np. 10 minut). Pole jest domyślnie zaznaczone, gdy klub ma włączoną natychmiastową płatność dla pracownika, i puste, gdy jej nie ma. Odznaczenie potwierdza rezerwację od razu, mimo ustawień klubu; zaznaczenie zakłada blokadę tam, gdzie klub jej nie wymaga.
Sekcja nie pojawia się dla rezerwacji za 0 zł ani dla kortu instruktora – nie ma tam czego blokować ani czego opłacać online. Link do płatności nie jest już niczyim wyborem przy rezerwacji – wychodzi sam, tam gdzie ma sens. Steruje tym klub raz, w ustawieniach powiadomień (Link do płatności (bez blokady terminu)), a nie recepcja przy każdej rezerwacji. Przy płatności natychmiastowej pole zostaje niezależnie od tego ustawienia, bo mówi wtedy o innej wiadomości (linku ratującym termin).
:::note Rezerwacja, która już trwa Gdy dopisujesz rezerwację na termin, który już się zaczął, SMS z linkiem nie wychodzi – link byłby martwy w chwili wysyłki. Należność zostaje normalnie do rozliczenia przy ladzie. :::
Link w mailu dostaje tak samo rezerwacja zrobiona przez klienta w aplikacji, jak i ta założona przy ladzie. Klient, którego powiadomienia idą SMS-em, dostaje ten sam link w treści potwierdzenia – żadne potwierdzenie rezerwacji nie kieruje już na ogólną listę płatności.
Mail z potwierdzeniem rezerwacji ma przy kwocie odnośnik „aby opłacić online kliknij tutaj" – prowadzi on wprost do tej rezerwacji, a nie do ogólnej listy płatności. Gdy nie ma czego opłacać online, odnośnik wraca do Portalu Klienta.
:::tip Klient lokalny dostaje link w SMS-ie Klient lokalny nie ma konta ani skrzynki, którą ktoś czyta – mail do niego nie dociera. Dlatego link do płatności wchodzi wprost w SMS z potwierdzeniem: „Potwierdzamy rezerwację kortu … Opłać online: …" – tak samo jak klient z kontem dostaje mail. Osobnego SMS-a z tym samym linkiem już nie wysyłamy, żeby nie dublować wiadomości. To samo dotyczy klienta zakładanego na poczekaniu w oknie rezerwacji. :::
Wygaśnięcie linku nie kasuje należności: płatność zostaje otwarta i wchodzi w zwykłe przypomnienia o zaległościach oraz w rozliczenie przy ladzie. Klient, który kliknie stary link, zobaczy „Link wygasł" i prośbę o kontakt z klubem.
:::warning „Bez powiadomienia" nie wyśle linku Zapis strzałką ▲ (bez powiadomienia) nie wysyła żadnej wiadomości, a więc także SMS-a z linkiem do płatności. :::
Rezerwacje cykliczne (stali klienci)
W sekcji Zajęcia cykliczne zaznacz powtarzalność (co tydzień / codziennie / co miesiąc) oraz zakończenie – po liczbie powtórzeń albo do konkretnej daty. Pod spodem zobaczysz, ile terminów zostanie utworzonych – po sprawdzeniu konfliktów licznik pokazuje realną liczbę, razem z informacją, ile terminów wypadło na dni wolne lub na zamknięcie kortu.
System sam sprawdzi konflikty z istniejącymi rezerwacjami. Jeśli któreś terminy są zajęte, wybierz Utwórz N rezerwacji (pomiń konflikty) – powstaną tylko wolne terminy, a zajęte zostaną pominięte. Dni wolne, święta oraz okresy zamknięcia kortu są pomijane zawsze, także przy pomijaniu konfliktów – seria przechodząca przez remont kortu powstanie, ale bez terminów z tego okresu.
Terminy są sprawdzane ponownie w chwili zapisu. Jeśli w międzyczasie ktoś zajął którykolwiek z nich, cała seria zostanie odrzucona z komunikatem o zajętym terminie – nic nie powstanie połowicznie. Odśwież konflikty i zapisz ponownie.
Cena niestandardowa wpisana przy serii obowiązuje na każdym terminie – tak samo jak przy rezerwacji jednorazowej. Sekcja Płatność działa tak samo jak przy rezerwacji jednorazowej, a link do płatności – tak jak przy holdzie serii – dotyczy pierwszego terminu i jest ważny do jego rozpoczęcia; kolejne rozliczasz normalnie.
Klient dostaje potwierdzenie rezerwacji cyklicznej: kort, adres, dzień tygodnia i godzina, zakres dat, liczba terminów, cena za termin i PIN do drzwi. Nie jest to już wiadomość o zapisie na zajęcia – nie ma w niej instruktora ani nazwy grupy.
:::warning Seria z natychmiastową płatnością Jeśli klub ma włączoną natychmiastową płatność, seria jest tylko zablokowana do czasu opłacenia – zobaczysz żółte ostrzeżenie z czasem (np. 10 minut). Klient dostaje jeden link, do pierwszego terminu. Opłacenie go potwierdza całą serię; pozostałe terminy rozliczasz normalnie przy ladzie. Jeśli nikt nie zapłaci w tym czasie, wszystkie terminy serii zostają zwolnione i korty wracają do sprzedaży. Wysłanie serii „bez powiadomienia" nie wyśle też linku – przy natychmiastowej płatności używaj zwykłego przycisku. :::
Podgląd i edycja rezerwacji
Kliknij kafelek rezerwacji w kalendarzu – otworzy się Szczegóły rezerwacji: typ (jednorazowa/cykliczna), kort, termin, cena, dane klienta i notatka.
- Kliknięcie w imię i nazwisko przenosi do profilu klienta.
- Kliknięcie w telefon lub e-mail kopiuje je do schowka.
Na dole są trzy przyciski: Odwołaj, Edytuj, Rozlicz (ten ostatni znika, gdy rezerwacja jest już opłacona).
Edytuj pozwala zmienić kort, datę, godziny, cenę, notatki i listę uczestników. Zapisujesz przyciskiem Zaktualizuj (lub „bez powiadomienia”). W oknie edycji jest też Podziel – dzieli jedną długą rezerwację na kilka segmentów z osobno wyliczoną ceną (np. gdy część godzin idzie po innej stawce).
:::warning Zajęty albo zablokowany termin Zapis zostanie odrzucony, jeśli nowy termin nachodzi na inną rezerwację (Termin zajęty) albo wchodzi w blokadę kortu lub godziny zamknięcia (Kort zamknięty). W obu przypadkach dotyczy to także samego przedłużenia – nie wydłużysz rezerwacji ani na czas, który ktoś zajął, ani w serwis.
Wyjątek: rezerwację, która już stoi w blokadzie (bo kort zablokowano po jej przyjęciu), możesz normalnie skrócić, wycenić i opisać – system nie zamraża jej tylko dlatego, że blokada pojawiła się później. :::
Gdy zmienisz godziny lub długość rezerwacji, cena przelicza się automatycznie – także dla klientów z indywidualną stawką. Wyjątkiem jest cena wpisana ręcznie w polu Cena niestandardowa: taka kwota jest traktowana jak świadoma decyzja i zostaje bez zmian, dopóki sam jej nie poprawisz albo nie wyczyścisz pola.
Edycja rezerwacji, która jest już częściowo rozliczona
Rezerwację można edytować także wtedy, gdy klient zapłacił już całość albo część kwoty – system sam odejmuje to, co zostało wpłacone.
- Cena, którą wpisujesz w edycji (również Cena niestandardowa), to zawsze cała cena rezerwacji, a nie kwota do dopłaty.
- Po zapisie w Szczegółach rezerwacji zobaczysz Cenę (pełną) i – jeśli coś zostało – Pozostało do zapłaty. To ta druga kwota trafia na przycisk Rozlicz.
- Podniesienie ceny dopisuje jedną dopłatę na brakującą różnicę. Wcześniejsze dopłaty z poprzednich edycji są anulowane, żeby nie sumowały się w nieskończoność.
- Obniżenie ceny poniżej tego, co klient już wpłacił, zwraca nadpłatę na jego portfel (od najnowszej wpłaty wstecz).
- Rozliczenie podzielone między graczy (przycisk podziału płatności przy ladzie) liczy się jak zapłacone – kolejna edycja go nie nadpisze.
Edycja całego cyklu
Gdy otworzysz do edycji rezerwację należącą do cyklu, na górze okna pojawi się niebieska ramka Zakres edycji z dwiema opcjami:
- Tylko ta rezerwacja – zmiana dotyczy jednego terminu (tak działało to wcześniej i tak jest domyślnie),
- Ta i wszystkie kolejne w cyklu (N) – zmiana obejmuje otwarty termin oraz wszystkie następne. W nawiasie widzisz, ilu terminów to dotyczy.
Typowa sytuacja: klient ma kort nr 1 w każdy piątek 16:00–17:00 i chce przenieść całą serię na kort nr 2. Otwórz dowolny przyszły termin z tej serii, wybierz Ta i wszystkie kolejne w cyklu, zmień kort i kliknij Zaktualizuj.
Na cały cykl przenoszą się tylko te pola, które faktycznie zmieniłeś: kort, dzień tygodnia i godziny, cena niestandardowa, notatki, uczestnicy. Jeśli zmieniasz sam kort, ceny i notatki wpisane kiedyś przy pojedynczym terminie (np. zniżka za odwołany przez deszcz trening) zostają nietknięte. Tak samo termin, który wcześniej przesunąłeś osobno na inną godzinę, nie wraca na godzinę serii, dopóki nie zmienisz godzin w tym oknie.
Cykl zmienia się od otwartego terminu w przód – wcześniejsze terminy zostają nietknięte. Otwarty termin zmienia się zawsze, także wtedy, gdy już się odbył (np. poprawiasz coś wstecz), i jest wliczony do liczby w nawiasie.
Jeśli przesuwasz cykl na inny dzień tygodnia, wszystkie terminy przesuwają się o tyle samo dni, więc odstępy między nimi zostają bez zmian.
:::warning Zajęte terminy Jeśli choć jeden z nowych terminów jest zajęty, zapis nie wykona się – zobaczysz żółte ostrzeżenie z listą kolidujących dat. Masz wtedy dwa wyjścia: Przenieś N wolnych terminów (kolidujące zostają na starym korcie i trzeba je obsłużyć osobno) albo Anuluj i wybrać inny termin. Tak samo zapis zostanie odrzucony, gdy któryś z terminów wypada w dniu zamknięcia kortu. :::
:::tip Jedno powiadomienie na cykl Klient dostaje jedną wiadomość o zmianie całej serii, a nie po jednej na każdy termin. Strzałka ▲ obok przycisku zapisu nadal pozwala zapisać zmianę bez powiadamiania klienta. :::
Rozliczenie płatności
Przy ladzie: Szczegóły rezerwacji → Rozlicz.
- Sprawdź kwotę do zapłaty (pole z imieniem i nazwiskiem możesz poprawić, np. na potrzeby paragonu).
- Jeśli klient płaci kartą benefitową – kliknij Multisport / Medicover / FitProfit / PZU Sport tyle razy, ile kart wnosi. Każde kliknięcie obniża kwotę o 15 zł (do zapłaty zostaje minimum 1 zł). Minus w rogu kafelka odejmuje kartę, Resetuj czyści wszystkie.
- Zaznacz Drukuj paragon, jeśli klient chce paragon.
- Wybierz formę płatności – obok gotówki, karty i portfela jest tam także
Wyślij link do płatności. Klient dostaje wtedy SMS-a z linkiem
prowadzącym wprost do tej należności, ważnym 48 godzin od wysłania, i
płaci z telefonu zamiast przy ladzie. Przycisk pojawia się tylko, gdy klient
ma numer telefonu, i gaśnie po rozliczeniu rezerwacji:
- Gotówka / Karta – rozliczenie w klubie,
- Portfel – pobiera środki z wirtualnego portfela klienta (w nawiasie widzisz jego saldo; przy niewystarczających środkach pobierze ile się da, a resztę trzeba dopłacić),
- Zaległość – klient wychodzi bez płacenia, kwota trafia na jego zaległości,
- Więcej opcji – płatność mieszana (np. część gotówką, część kartą).
Jeśli klient prosił o fakturę, nad kwotą pojawi się niebieska ramka z danymi do faktury – to sygnał, żeby nie wystawiać zwykłego paragonu.
Rezerwacja podzielona między dwie osoby
Kwotę rezerwacji można rozbić na kilku płatników: Rozlicz → Więcej opcji, dodaj wiersz z drugą osobą i kwotą, a potem Zatwierdź podział. Każdy płatnik dostaje własną pozycję, także wtedy, gdy nie jest wpisany na listę uczestników rezerwacji.
Dopóki choć jedna pozycja czeka na wpłatę, ikona pieniędzy na kafelku w kalendarzu zostaje czerwona, nawet jeśli pierwsza osoba zapłaciła swoją połowę tydzień wcześniej. Ile dokładnie zostało, zobaczysz w Szczegółach rezerwacji w polu Pozostało do zapłaty. Zielona ikona oznacza, że wpłynęła cała kwota rezerwacji – nie tylko część.
Znacznik płatności przy nazwisku klienta jest zielony dopiero wtedy, gdy nie zostało już nic do zapłaty. Dopóki brakuje choćby części kwoty – bo druga osoba z podziału jeszcze nie zapłaciła albo została dopłata po zmianie rezerwacji – znacznik pokazuje kwotę zaległą, nie tę już wpłaconą.
Obie pozycje widać też przy nazwisku klienta w oknie edycji rezerwacji: kliknięcie znacznika płatności otwiera Szczegóły płatności z ramką Płatności rezerwacji – Razem / Opłacone / Pozostało – i przełącznikiem między pozycjami, gdzie każda podpisana jest nazwiskiem osoby, która ją płaci. Stamtąd otworzysz też paragon każdej z nich osobno.
Gdy druga osoba przychodzi zapłacić, klikasz Rozlicz tak samo jak zwykle: okno rozliczenia otwiera się na jej pozycji – z jej kwotą, jej nazwiskiem na paragonie i jej portfelem – nawet jeśli osoba, na którą zapisana jest rezerwacja, zapłaciła już wcześniej. Przycisk Rozlicz znika dopiero wtedy, gdy nie zostało nic do zapłaty.
Odwoływanie rezerwacji
Szczegóły rezerwacji → Odwołaj. W oknie potwierdzenia:
- przy rezerwacji opłaconej zobaczysz zaznaczony checkbox „Zwróć X zł na portfel klienta” – zostaw go zaznaczonego przy zwykłym odwołaniu; odznacz tylko wtedy, gdy zwrot się nie należy,
- Odwołaj rezerwację – klient dostaje powiadomienie o odwołaniu,
- strzałka ▼ → Odwołaj bez powiadomienia,
- przy rezerwacji cyklicznej dostępne jest Odwołaj całą serię – zobaczysz listę wszystkich przyszłych terminów, zanim potwierdzisz.
:::warning Zwrot to portfel, nie przelew Zwrócone pieniądze trafiają do wirtualnego portfela klienta i pokrywają kolejne rezerwacje. Nie jest to przelew na konto ani zwrot na kartę. :::
Szukanie rezerwacji
Gdy klient dzwoni i nie wiesz, kiedy ma kort: menu → Historia → filtr Przegląd rezerwacji. Masz tam wyszukiwarkę (nazwisko, telefon) oraz statusy (aktywna / odwołana / wygasła) i źródło rezerwacji (online albo „rezerwacja telefoniczna”, czyli założona przez pracownika). Kliknięcie pozycji otwiera to samo okno Szczegółów rezerwacji, z którego odwołasz i rozliczysz.
PIN do drzwi
Każdy uczestnik ma jeden stały 4-cyfrowy PIN, który otwiera drzwi na wszystkich jego rezerwacjach. Nie generuje się nowego kodu do każdej rezerwacji.
Aby go podejrzeć lub zmienić: menu → Uczestnicy → ⋮ przy wierszu → PIN do drzwi. Klient widzi swój PIN w aplikacji w sekcji Moi gracze.
Najczęstsze sytuacje
| Sytuacja | Co zrobić |
|---|---|
| Klient chce przesunąć godzinę | Szczegóły → Edytuj → zmień godziny → Zaktualizuj |
| Klient chce inny kort | Szczegóły → Edytuj → zmień Kort |
| Klient nie przyszedł i nie zapłacił | Rozlicz → Zaległość |
| Klient płaci połowę gotówką, połowę kartą | Rozlicz → Więcej opcji |
| Rezerwacja cykliczna kończy się wcześniej | Odwołaj → Odwołaj całą serię (odwoła przyszłe terminy) |
| Klient chce przenieść cały cykl na inny kort | Szczegóły → Edytuj → Zakres edycji: Ta i wszystkie kolejne → zmień Kort |
| Nie widać wolnego terminu, choć kort pusty | Sprawdź miasto/lokalizację na górze i godziny otwarcia kortu (przycisk Godziny otwarcia) |
🛠️ Dokumentacja techniczna
Ścieżki i komponenty
| Element | Plik |
|---|---|
| Kalendarz recepcji | app/(dashboard)/dashboard/schedule (BookingClient.tsx) |
| Formularz nowej rezerwacji | app/(dashboard)/dashboard/schedule/components/ReservationForm.tsx |
| Przełącznik Rezerwacja/Aktywność | app/(dashboard)/dashboard/schedule/components/GameFormContainer.tsx |
| Edycja rezerwacji + podział | EditReservationForm.tsx, SplitReservationDialog.tsx |
| Zakres edycji cyklu | ReservationSeriesScope.tsx, RecurringConflictWarning.tsx |
| Szczegóły rezerwacji | components/schedule/reservation-details-dialog.tsx |
| Rozliczenie / płatność mieszana | reservation-settle-dialog.tsx, reservation-advanced-payment-dialog.tsx |
| Odwołanie | components/schedule/reservation-cancel-dialog.tsx |
| Lista rezerwacji dla pracownika | app/(dashboard)/dashboard/history (/reservations-overview przekierowuje tutaj) |
Logika serwerowa
-
createAdminReservation(lib/actions/booking.ts) – tworzy grę, booking, opcjonalną płatność i wysyła powiadomienia. Gdyimmediate_payment_employee(lub indywidualnerequire_online_paymentklienta) jest włączone, rezerwacja i płatność dostająexpires_at= teraz +employee_payment_timeminut, a gra powstaje bez płatności (createGameWithoutPayment). -
Opcje płatności z formularza nadpisują ustawienia klubu.
createAdminReservationigenerateRecurringReservationsprzyjmująrequire_immediate_paymentisend_payment_link. Pierwsza trafia dorequiresImmediateCourtPaymentjakoemployeeOverridei stoi wyżej niżrequire_online_paymentklienta iimmediate_payment_employeelokalizacji, a niżej niż cena<= 0, kort instruktora i zawieszenie za zaległości – to nie są preferencje płatności, tylko reguły, których recepcja nie znosi. Formularz startuje z wartością, którą zwróciłby sam predykat, więc nieruszony wygląda dokładnie jak przed zmianą. Obie opcje honorujemy tylko dla sesjiADMIN/BACKOFFICE– payload klienta ich nie zmienia. -
Link bez holdu. Gdy rezerwacja nie jest blokowana, a pracownik zaznaczył wysyłkę linku,
sendOptionalPaymentLinkSms(lib/actions/booking.ts) bierze istniejącą płatność (findSettleableGamePaymentIddla jednorazowej,generatedGames[0].paymentIddla serii – w obu przypadkach wystawił ją jużcreateGame/generateRecurringGames), zakładapayment_linkwygasający w chwili rozpoczęcia rezerwacji (expiryMinutes= minuty dostart_time, dla serii do startu pierwszego terminu) i wysyła SMSreservation_payment_link_optional. Ten sam token trafia dopayUrlw danych potwierdzenia (existing_player_added_to_reservation,player_added_to_recurring_reservation), więc mail i SMS wskazują jedną płatność – dwa tokeny pozwoliłyby opłacić przez jeden i zastać martwy drugi. Link powstaje dla każdej niezablokowanej rezerwacji z powiadomieniami. -
SMS nie jest Handlebarsem.
interpolate(lib/notifications.ts) rozumie wyłącznie{{#if x}}…{{/if}}– nie ma gałęzi{{else}}, która dojechałaby do klienta jako zwykły tekst. Dlatego w SMS-ach zdanie o Portalu Klienta jest usuwane, a link dokładany warunkowo, zamiast trzymać portal jako fallback (tak jak robi to szablon maila, renderowany już przez prawdziwy Handlebars SendGrida). Brak linku znaczy po prostu brak zdania o płatności. -
Zmienne w
notification_textssą w pojedynczych klamrach.{payUrl},{doorPin}– podwójne rezerwuje{{#if …}}/{{/if}}.interpolate(lib/notifications.ts) rozumie oba zapisy, więc pomyłka nie wywraca wysyłki, ale migracja szukająca tekstu z podwójnymi klamrami nie dopasuje niczego i przechodzi jako cichy no-op. Pilnuje tego__tests__/lib/walk-in-payment-link-migration.test.ts, który uruchamia0274na tekstach zasianych przez0218/0245i porównuje wynik zmessages/*.json. -
Nadpisanie z formularza jedzie tylko wtedy, gdy ktoś ruszył przełącznik.
paymentPolicydociera z serwera chwilę po otwarciu okna, a nietknięte pole pokazuje domyślną wartość policzoną jeszcze bez ustawień klienta. Wysyłanie jej jako odpowiedzi przesłaniałobyallow_arrearsirequire_online_paymenttego klienta, więc formularz posyłanull, dopóki pracownik sam czegoś nie kliknie. Zmiana uczestnika kasuje wcześniejszą decyzję i przywraca domyślną wartość policzoną dla nowej osoby. -
Klient bez konta dostaje link w potwierdzeniu, nie w osobnym SMS-ie.
clientHasOwnAccount(lib/actions/booking.ts) rozstrzyga jedną regułą, używaną i przy rezerwacji jednorazowej, i przy serii: adres placeholdera (client_idzaczynające się odlocal|) oznacza, że jedynym kanałem jest SMS. Dlategoplayer_added_to_reservationiplayer_added_to_recurring_reservationniosą warunkowe{{payUrl}}(migracja0274, z zachowaniem treści nadpisanych przez klub – wycina zdanie o Portalu Klienta tam, gdzie stoi domyślne brzmienie, i dokleja klauzulę każdemu tekstowi, który jej nie ma, wzorem0198; dopasowanie do całej wiadomości nie zadziała, bo brzmienie różni się między tenantami), areservation_payment_link_optionaljest wtedy pomijany – ten sam link w dwóch SMS-ach w tej samej minucie to tylko koszt i szum. -
Dedykowany SMS idzie tylko tam, gdzie potwierdzenie nie dotarło SMS-em – nie ma nad nim przełącznika w formularzu, bo reguła nie zostawia miejsca na wybór: przy płatności natychmiastowej link i tak leci, klient lokalny i tak dostaje SMS, a klientowi z kontem link niesie potwierdzenie. Zostaje jedynie przypadek, w którym potwierdzenie idzie samym mailem – i tam wiadomość wychodzi sama, o ile klub ma dla niej włączony kanał SMS. Klient z kontem też bywa powiadamiany SMS-em – zależy to od przełączników kanałów klubu dla danego typu i od tego, czy sam go nie wyłączył, więc nie da się tego wyczytać z rekordu gracza. Odpowiada na to
notificationReachesSms(lib/notifications.ts), pytany przed wysyłką; współdzieli regułęsmsChannelCarriesz samą wysyłką, żeby obie strony nie rozjechały się w tym, co uznają za kanał SMS. -
Ręczna wysyłka linku ma własny typ powiadomienia.
sendReservationPaymentLink(lib/actions/booking.ts), wołane z okna rozliczenia, wysyłareservation_payment_link_reminder, a nie..._optional: ten drugi mówi „do rozpoczęcia rezerwacji", co przy ściąganiu należności po odegranym meczu było nieprawdą. Stąd też oknoRESERVATION_PAYMENT_REMINDER_VALID_MINUTES= 48 h liczone od wysłania, zamiast terminu związanego ze slotem, który zwykle już minął. Kwota do zapłaty jest odczytywana na serwerze (getReservationDetails, to samototals.outstanding, które widzi dialog), a nie brana z ekranu, który poprosił – okno mogło stać otwarte, gdy ktoś rozliczał tę rezerwację przy ladzie. Akcja jest dostępna tylko dla ADMIN/BACKOFFICE. -
clientHasOwnAccount(lib/utils/client-account.ts) rozstrzyga, kto jest klientem lokalnym – jedna reguła dla rezerwacji jednorazowej i serii. -
Treść maili żyje w SendGridzie, nie w repo.
lib/notifications.tswysyła wyłącznie przeztemplateId(szablony dynamiczne), amails/*.htmlto kopia do podglądu w panelu powiadomień. Odnośnik przy kwocie ma w repo postać{{#if payUrl}}{{payUrl}}{{else}}https://klient.acepark.pl/dashboard/payments{{/if}}– żeby zadziałał u klienta, trzeba tę samą zmianę wprowadzić w szablonach SendGridad-6249ddecec444103908dcab96f1e9c0f(rezerwacja jednorazowa) id-800d5d9e7d8441c882fb4d2fff120c71(seria). Do tego czasu mail wygląda jak dotychczas: odnośnik prowadzi do listy płatności w Portalu Klienta. -
Klient z włączonym rozliczeniem zaległością nie dostaje holdu – rezerwacja jest potwierdzona od razu, a należność czeka na rozliczenie. Ustawienie „Zawsze wymagaj płatności z góry" na kliencie i zawieszenie za zaległości po terminie są od tego mocniejsze.
-
Cena liczona jest przez
calculateActivityPricez uwzględnieniem reguł cenowych i nadpisań klienta (applyClientPricingOverride);custom_pricez formularza nadpisuje wynik. Cena jest ustalana przed decyzją o holdzie, bo to ona o niej współdecyduje. -
Rezerwacja za 0 zł nigdy nie dostaje holdu –
requiresImmediateCourtPayment(lib/utils/immediate-payment.ts) zwracafalsedla ceny<= 0, niezależnie od ustawień lokalizacji, nadpisania klienta i zaległości.createPaymentrozlicza zerową kwotę już przyINSERT(statuspaid), więc żadna późniejsza zmiana statusu nie nadejdzie, aclearGameExpiresAt/clearBookingExpiresAt– jedyne miejsca zdejmujące blokadę – nigdy by się nie uruchomiły: kort stałby za terminem, którego nic nie potrafi wyczyścić, i po cichu przepadł. Zamiast tego gra powstaje przezcreateGame, uczestnik nie jestdraft,booking_expires_atzostaje puste, a klient dostaje zwykłe potwierdzenie zamiast SMS-a z linkiem do płatności. Ta sama reguła obowiązuje w rezerwacji klienckiej i w serii. Formularze (ReservationForm,court-booking-dialog) czytają ten sam predykat i podają mu kwotę, która faktycznie zostanie policzona, więc żółte ostrzeżenie o czasie na płatność nie pokazuje się dla ceny 0. -
Obniżenie ceny do 0 na już istniejącej rezerwacji z holdem domyka się w
updateReservation:applyReservationTotalPriceChangetylko przepisuje kwotę i zostawia płatność jakopending, a linku na 0 zł nie da się opłacić (/api/public/payment/initializeodrzuca kwoty<= 0), więc żadna zmiana statusu by nie nadeszła i hold wygasłby po cichu. Dlatego akcja sama rozlicza taką płatność (status = 'paid',payment_expires_at = NULL) i zdejmuje blokadę przezclearGameExpiresAt+clearBookingExpiresAt– ten sam chokepoint, co przy potwierdzeniu wpłaty. Płatność już opłaconą zostawia w spokoju (obniżka idzie wtedy zwykłą ścieżką zwrotu do portfela). -
Przecenienie rezerwacji liczy się od sumy, nie od pojedynczego wiersza. Rezerwacja jest warta sumę swoich płatności: rozliczenie części kwoty przy ladzie (
extractAndSettlePartialPaymentInClub) rozbija pierwotny wiersz na dwa, a podział między graczy dokłada kolejne. Dlatego wszystkie trzy ścieżki rezerwacji kortu (updateReservation,updateRecurringReservationSeries,splitReservation) wołająapplyReservationTotalPriceChange(lib/actions/payment.ts). Do 09.2026 zajęcia używały osobnegohandlePriceChangeForPayments, który porównywał nową cenę z każdym wierszem osobno i zawyżał rachunek podzielony w recepcji. Teraz zajęcia korzystają z tego samegoplanTotalPriceChange, tylko osobno dla każdego uczestnika (docs/docs/payments.md, „Przeliczenie ceny jako jedna paczka").applyReservationTotalPriceChangebierzenewTotaljako całą cenę: odejmuje sumę wierszy rozliczonych, resztę wkłada w jeden otwarty wiersz (a gdy żadnego nie ma – zakłada jedną dopłatę), pozostałe otwarte wiersze z wcześniejszych edycji ustawia nacancelled, a nadpłatę zwraca do portfela idąc od najnowszej wpłaty wstecz. Wierszecancelled,refundediexpiredsą pomijane. Otwarty wiersz zostaje z kwotą0(zamiast być anulowany), gdy wyjdzie zero – tego wiersza potrzebujesettleHoldOnReservationEditedToFree, żeby zdjąć hold z rezerwacji przecenionej do zera. Za „rozliczone" uznajeisSettledPaymentStatus(lib/utils/payment-status.ts), a więc pełną listęSETTLED_PAYMENT_STATUSESrazem zpaid_splitipaid_split_partial. PrywatneisPaidStatuswpayment.tstych dwóch nie zna, a raporty finansowe liczą je jako wpłacone – bez tego edycja rezerwacji rozliczonej podziałem nadpisałaby kwotę, którą klient już zostawił w kasie. -
Ikona rozliczenia na kafelku w kalendarzu i przy nazwisku klienta czyta
paymentszgetGamesByCity, a ta zwraca jeden wiersz na miejsce. Miejsce nazywa swoją płatność (attendee.paymentId), więc po przedłużeniu opłaconej rezerwacji wygrywał wiersz opłacony, a dopłata w ogóle nie docierała do widoku: slot świecił na zielono, choć klient był winien różnicę. Wiersz z niezapłaconą kwotą ma teraz pierwszeństwo przed nazwanym — miejsce jest rozliczone dopiero, gdy nic żywego nie zostało do zapłaty. Wierszecancelled,refundediexpirednadal nie liczą się jako dług, więc miejsce odtworzone po zwolnionym zapisie dalej wygląda poprawnie. -
Podział płatności między dwie osoby nie mieści się w liście miejsc.
extractPaymentToNewPlayer(lib/actions/payment-partial.ts) wystawia drugą połowę na wybranego płatnika, a ten nie musi siedzieć na rezerwacji. Payments zgetGamesByCityszły przezpayment_related_gamezłączone po graczu z listy uczestników, więc wiersz drugiej osoby nie docierał do kalendarza: gdy pierwszy klient płacił swoją połowę, kafelek świecił na zielono, choć połowa kwoty wciąż była do zapłaty (AP-1023). Zapytanie dokłada teraz osobną kolumnęunseated_payments_data– żywe płatności rezerwacji (at.type = 'booking') wiszące na graczu spoza listy uczestników. Trafiają one na koniec tablicypayments, za wierszami miejsc: znaczniki przy nazwiskach dalej czytają swój wiersz po indeksie (findSeatPaymentdopasowuje poid/player_id), a pytanie „czy cały slot jest rozliczony" widzi już cały rachunek. Wierszecancelled,refundediexpiredodsiewa SQL, tak samo jakpendingz wygasłym holdem (payment_expires_atw przeszłości) – nic nie są winne, agetReservationDetailsliczy Pozostało do zapłaty dokładnie tak samo, więc kafelek i szczegóły mówią to samo. Zajęcia grupowe zostają nietknięte – tam każdy uczestnik ma własny komplet i płatność gracza spoza listy jest historią po wypisaniu, a nie długiem. -
details.paymentto wiersz rezerwującego, nie wiersz do rozliczenia. Pierwsze zapytaniegetReservationDetailszawężaLEFT JOIN paymentdob.booked_for_player_id, więc gdy rezerwujący zapłacił swoją część, a druga osoba jeszcze nie,ReservationSettleDialogbrał jego opłacony wiersz jakoactivePayment, wpadał w gałąźisPaidi pokazywał „płatność już rozliczona" – mimo że w szczegółach stało Pozostało do zapłaty 20 zł i przycisk Rozlicz był aktywny.getReservationDetailsoddaje teraz osobneoutstandingPayment: pierwszy nierozliczony wiersz rezerwacji (isSettledPaymentStatus) razem z płatnikiem, do którego należy (LEFT JOIN playerpopay.player_iddaje imię, nazwisko iowner_email). Okno rozliczenia bierze go przeddetails.payment, więc kwota, nazwisko na paragonie, portfel, zaległość i dane do faktury dotyczą tej osoby, która faktycznie płaci.details.paymentzostaje bez zmian, bo z niego liczy się komunikat o zwrocie przy odwoływaniu rezerwacji. Zajęcia grupowe wybierają uczestnika przezattendeePayments, więc tamoutstandingPaymentjest celowo pomijany. -
Znacznik na linii uczestnika czyta całe miejsce, nie jeden wiersz. Wcześniej wybierał wiersz rozliczony, żeby dopłata nie wymazała z linii przyjętych pieniędzy – ale wtedy miejsce z otwartą kwotą świeciło zielonym haczykiem.
pickSeatMarkerPayment(lib/attendee-seat-payment.ts) odwraca pierwszeństwo: cokolwiek zostało do zapłaty bije to, co już wpłynęło, więc linia mówi „czy to miejsce jest opłacone", a komplet wierszy i tak widać w szczegółach. Miejsce, na które nic jeszcze nie wpłynęło, dalej nie dostaje znacznika (jest tam przycisk rozliczenia i ikon wystarczy), a wierszpaid_split_partialmówi sam za siebie – jego odroczona część siedzi wlinked_payment, nie w osobnym wierszu, więc tylko status może powiedzieć, że rachunek nie jest zamknięty. Szczegóły otwierają się teraz także spod „nieopłaconego" znacznika (hasSettledSeatMoney), bo to właśnie tam recepcja sprawdza, co już wpłynęło. -
Lista miejsca oddaje też płatników spoza listy uczestników.
getLivePaymentsForAttendeeczytała wyłącznieprg.player_id = ?, więc po podziale rachunku recepcja widziała przy nazwisku jedną połowę i ani śladu drugiej –PaymentDetailsDialogdostawał jednoelementowesiblingsi nie pokazywał nawet ramki z sumą. Zapytanie dokłada teraz (tylko dlapayment_type = 'court_reservation') wiersze wiszące na graczu, którego nie ma wgame.attendees. Tylko dla recepcji – ten odczyt jest otwarty także dla klienta, który jest właścicielem miejsca (validatePlayerOwnership), a cudzy wiersz niesie imię, nazwisko i e-mail drugiego płatnika; klient dalej widzi wyłącznie swoje wiersze. Zajęcia grupowe zostają przy swoim miejscu: tam każdy uczestnik płaci własną pełną stawkę, a płatność gracza spoza listy to historia po wypisaniu, nie dług. Skoro na linii uczestnika może teraz stanąć cudzy wiersz,MarkAsPaidButtonbierze płatnika z wiersza, a nie z miejsca (imię na paragon, portfel, zaległość), a efekt stemplującyattendee.paymentIdpomija wiersze, które do miejsca nie należą. -
Podział nie dziedziczy holdu rezerwacji.
extractPaymentToNewPlayerkopiowałopayment_expires_atz dzielonego wiersza, więc połowa wystawiona przy ladzie dostawała 10-minutowy hold liczony od rozpoczęcia pierwotnej płatności. Po jego upływie leniwe filtry (getGamesByCity,getReservationDetails,getLivePaymentsForAttendee) przestawały ją widzieć – kafelek zielenił się, „Pozostało do zapłaty" spadało do zera – a sweepexpirePendingPaymentsprzestawiał dług naexpiredi wysyłał klientowi powiadomienie o wygaśnięciu. Nowy wiersz nie dostaje jużpayment_expires_at(hold zostaje na wierszu, z którym rezerwacja powstała), a migracja0271czyści go na istniejących wierszachpending. Wierszy już przestawionych naexpiredmigracja nie rusza – wśród nich są prawdziwie porzucone płatności online i odróżnia je tylko człowiek. -
Termin płatności dziedziczony przez podział nie jest walidowany. Rezerwacja kortu dostaje termin w trybie
immediate, czyliteraz + 60 sekund(getPaymentDueDateForGame), i taki termin zostaje zapisany na wierszu przy każdej zmianie ceny. Minutę później jest już przeszłością – acreatePaymentodrzucał każdą datę z przeszłości (Due date must be in the future), więc podział takiej rezerwacji przy ladzie nigdy nie mógł się udać: recepcja dostawała czerwony komunikat Next.js i klikała w kółko bez skutku. Podział nie tworzy nowego zobowiązania, tylko wykrawa część z długu, który już istnieje, a ta część zachowuje terminy wiersza, z którego powstała. DlategocreatePaymentprzyjmujedatesInheritedFromPaymentId: wskazanie wiersza źródłowego wyłącza kontrolę „data musi być w przyszłości" dladueDateipaymentExpiresAt(assertDueDateIsAcceptable,assertPaymentExpiryIsAcceptablewlib/payment-due-date.ts). Kontrola zostaje w mocy wszędzie tam, gdzie termin jest ustawiany – nowy dług dalej nie może urodzić się przeterminowany. Błędny format daty jest odrzucany niezależnie od dziedziczenia. -
Zielona ikona to
FULLY_PAID_PAYMENT_STATUSES, nieSETTLED_PAYMENT_STATUSES. Podział rozliczony przy ladzie z odroczoną częścią zostawia na wierszu nadrzędnympaid_split_partial; finanse liczą go jako wpłacony (patrz wyżej), ale slot nie jest opłacony do końca. Kalendarz desktopowy miał na to własną lokalną listę, a widok mobilny (MobileScheduleIos) sięgał poPAID_PAYMENT_STATUSESzlib/analytics/payments.tsi pokazywał zielono. Obie ścieżki czytają terazFULLY_PAID_PAYMENT_STATUSESzconstants/data.ts–SETTLED_PAYMENT_STATUSESbez obu statusów „rozliczone częściowo":paid_split_partialipaid_partial_wallet. ObaPaymentStatusrysuje na własnym wierszu na bursztynowo, więc kafelek mówi teraz to samo, co szczegóły. Stałej analitycznej nie ruszamy, bo z niej liczą się przychody. -
Znaczniki płatności przy uczestniku w oknie edycji (
AttendeeInfo) pokazują wszystkie żywe wiersze miejsca, a nie jeden. Wcześniej komponent pytał/api/paymentso płatność nazwaną przez miejsce, więc po przedłużeniu rozliczonej rezerwacji świecił zielony haczyk opłaconej gotówki, choć została dopłata; wybranie samej dopłaty z kolei chowałoby wpłatę, którą recepcja już przyjęła./api/payments?all=trueoddaje teraz komplet przezgetLivePaymentsForAttendee(nieuregulowane pierwsze, bez wierszycancelled/refundedi bez wygasłych holdów), a wiersz jest renderowany na linii uczestnika: nieopłacony jako przycisk rozliczenia, opłacone jako klikalne znaczniki otwierające szczegóły płatności i paragon.getPaymentByIdOrForAttendee(ścieżka pojedynczego wiersza) też stawia nieuregulowaną kwotę przed wierszem nazwanym. -
Linia uczestnika zostaje przy jednym znaczniku, bo ikon jest tam już dużo: pokazuje wiersz rozliczony (żeby dopłata nie wymazała przyjętych pieniędzy), a obok stoi przycisk rozliczenia tego, co zostało. Komplet widać dopiero po kliknięciu:
PaymentDetailsDialogdostajesiblingsi przy więcej niż jednym wierszu otwiera się nagłówkiem „Płatności rezerwacji" z sumami (razem / opłacone / pozostało) oraz przełącznikiem, który przestawia treść okna na wybraną płatność wraz z jej paragonem. -
Płatności klienta (
/dashboard/payments) nie wymagały zmiany: dopłata to zwykły wiersz z jegoplayer_idiuser_email, więc wchodzi do podsumowania miesiąca i do kwoty „do zapłaty" jak każda inna należność. -
Wszystkie kwerendy, które przeceniają rezerwację albo liczą jej sumy, dopasowują
related_ids[0], a nie dowolny element tablicy.createPaymentsForRecurringSerieszapisuje[gameId, seriesId], arecurring_game_series.iddzieli przestrzeń numerów zgame.id— dopasowanie „gdziekolwiek" doklejało płatności serii do zajęć o numerze równym numerowi serii (stąd migracja 0261 i tabelapayment_related_game). Przy sumowaniu wierszy taka kolizja nie tylko myli widok, ale zawyżasettledTotali wystawia złą dopłatę.getLivePaymentsForAttendeeczyta przez indeksowanąpayment_related_game, pozostałe przezjson_extract(related_ids, '$[0]'). -
getReservationDetailszwraca obok pojedynczej płatności (payment, dalej używanej przez okna rozliczenia i odwołania) takżepayments– wszystkie aktywne wiersze gry – oraztotals(price,settled,outstanding). Wcześniej okno szczegółów pokazywało jako „cenę" jeden wiersz wybrany przezLIMIT 1z preferencją dlapending, więc rezerwacja o kilku wierszach pokazywała przy Rozlicz kwotę inną niż w edycji. Obie kwerendy idą jednymdb.batch. -
updateRecurringReservationSeries(lib/actions/booking.ts) – jedna edycja rozniesiona po całym cyklu. Edytowany termin jest kotwicą: brane są wszystkie wystąpienia serii od niego w przód (i nigdy te, które już się zaczęły), a każde przesuwa się o tę samą liczbę dni kalendarzowych i przyjmuje nową godzinę. Daty liczone są w strefieEurope/Warsaw(toPolandDateISO+addDaysToDateISO+zonedTimeToUtc), a nie przez dodanie stałej liczby milisekund – cykl przechodzący przez zmianę czasu inaczej zsunąłby się o godzinę. Konflikty sprawdza jednodb.batchz zapytaniem wykluczającym własne wystąpienia serii (inaczej seria kolidowałaby sama ze sobą przy zmianie samej godziny). Gdy któryś termin jest zajęty, akcja nie zapisuje niczego i zwraca{ applied: false, conflicts }; dopieroskipConflicting: trueprzenosi tylko wolne terminy. Zapisy idą partiami: jednodb.batchna wierszegame(kort, godziny, uczestnicy, wpis doevent_logprzezjson_insert), drugie na wierszebooking, trzecie na płatności. Wierszrecurring_game_seriesteż dostaje nowy kort, godziny i dzień tygodnia – zostawiony stary kort byłby tym, na którym powstałyby kolejne wystąpienia. Kolumny terminu (court_id,start_time,end_time) zapisywane są tylko gdy edytowany termin faktycznie się przesunął (slotChanged) – inaczej edycja samej ceny wciągnęłaby na godzinę serii wystąpienie przesunięte wcześniej osobno, i to w gałęzi, w której konflikty nie są w ogóle sprawdzane. Z tego samego powoducustom_price,internal_notes,applied_pricing_rule_idibooked_for_player_ididą przezCASE WHEN ? = 1, każde pod własną flagą „naprawdę się zmieniło": cena ustawiona kiedyś na jednym terminie przeżywa przeniesienie kortu. Urządzenia przy drzwiach dostają zmianę zbiorczo (captureHardwareSessionStates+syncSessionsToHardware), a klient jedno powiadomieniereservation_moved/reservation_court_changedopisujące pierwszy termin, który jeszcze się nie odbył (edytowany może być już przeszły) – po jednym na każde wystąpienie byłaby lawina SMS-ów. Wysyłkę dzieli zupdateReservationprzez wspólnenotifyReservationChanged. -
generateRecurringReservations(lib/actions/booking.ts) – odpowiednik dla serii. Sprawdza zaległości, wyrównanie do siatki oraz zamknięcia kortu dla każdego terminu z osobna (godziny otwarcia mogą się zmienić w trakcie serii), a przed pierwszym zapisem ponownie weryfikuje dostępność wszystkich terminów i przerywa całą serię błędemCOURT_SCHEDULE_OVERLAP.custom_price(tylko dlaADMIN/BACKOFFICE) trafia do kolumnycustom_pricekażdej gry, do wierszabookingi do kwoty płatności. -
Hold serii: gdy
requiresImmediateCourtPaymentzwrócitrue,generateRecurringReservationszapisuje termin blokady wgame_expires_atkażdej gry,booking_expires_atkażdego wierszabooking, w polachdraft/paymentStartedAt/paymentExpiresAtuczestnika oraz wpayment_expires_atkażdej płatności. Link do płatności idzie tylko dla pierwszego terminu (sendReservationNotifications), a potwierdzenie tej płatności zwalnia całą serię przezreleaseRecurringCourtSeriesHold(lib/series-reservation-hold-sql.ts) wpięte wclearGameExpiresAt– ten sam chokepoint, przez który przechodzą wszystkie potwierdzenia płatności. Zwolnienie omija terminy, których slot ktoś zajął w czasie, gdy blokada była wygasła – wskrzeszenie ich oznaczałoby podwójną rezerwację; taki termin zostaje wygaszony, a jego płatność wygaszaexpirePendingPayments, więc klient nie płaci za kort, który przepadł. Gdy seria nie wygenerowała płatności za pierwszy termin (typ aktywności, której cena nie trafiła na płatność), akcja zakłada ją sama – bez płatności nie byłoby linku, a blokada wygasłaby po cichu. Seria wyceniona na 0 zł w ogóle nie dostaje holdu (patrz reguła zerowej ceny wyżej). Brak wpłaty =expirePendingPaymentswygasza płatności, a leniwe filtry nagame_expires_atzwalniają korty. Kształt skopiowany zaddPlayerToRecurringSeries(zapis na zajęcia stałe). -
generateRecurringGames(lib/actions/game.ts) zenforceCourtAvailability: truewstawia każde wystąpienie zapytaniemINSERT ... WHERE NOT EXISTS, tak jakcreateGame. Przegrany wyścig o termin cofa wszystkie wystąpienia zapisane w tym wywołaniu razem z ich płatnościami, a formularz kasuje pustą serię. -
Powiadomienie o serii:
player_added_to_recurring_reservation(lib/notification-config.ts, szablonmails/recurring-reservation-confirmation.html, migracja0242). Wysyła jegenerateRecurringReservationspo utworzeniu bookingów – dopiero wtedy znany jest PIN serii.generateRecurringGamesdostajesendNotification: falsez tej ścieżki, żeby nie poszła wiadomość o zapisie na zajęcia (player_added_to_recurring_series), która mówiła o instruktorze. Klient z kontem dostaje ją kanałami ze swoich ustawień, a klient założony przy ladzie (kontolocal, adres bez skrzynki) – SMS-em, tak samo jak przy jednorazowej. Przy aktywnym holdzie zamiast potwierdzenia idzie SMS z linkiem, a po zapłaciebooking_payment_success. -
Zamknięcia i blokady kortu (
court.closures) sprawdzafindClosureForInstant(lib/utils/opening-hours.ts) – godzinę i dzień czyta w strefieEurope/Warsaw, więc Worker w UTC widzi to samo, co kalendarz. Serwer odrzuca zamknięty termin błędemCOURT_CLOSEDprzy zakładaniu rezerwacji (klienckiej i przy ladzie), przy edycji cyklu, przy generowaniu terminów serii oraz przy edycji pojedynczej rezerwacji. Blokady nie są wierszami wgame, tylko JSON-em na korcie, więc kwerenda konfliktów (checkCourtConflictForUpdate) ich nie widzi i każda ścieżka zapisująca termin musi zapytać osobno. -
updateReservationporównuje cały zakres, nie sam moment rozpoczęcia:findClosuresOverlappingRange(lib/utils/opening-hours.ts) zwraca wszystkie blokady, przez które przechodzi termin, licząc dni w strefieEurope/Warsawi radząc sobie z zakresem przez północ. Bez tego wydłużenie rezerwacji z zewnątrz w blokadę przechodziło – godzina rozpoczęcia zostawała niewinna, a kontrolka długości w formularzu nie jest przycinana przy blokadach (inaczej niż przy zajętym terminie). Odrzucana jest tylko blokada, w której rezerwacja jeszcze nie stała: stare i nowe wystąpienia są porównywane, więc kort zablokowany po przyjęciu rezerwacji nie zamraża jej – da się ją skrócić, wycenić i opisać. Przeniesienie na inny kort tej ulgi nie dostaje, bo blokad tamtego kortu nikt nie zaakceptował. Przy okazji predykat „czy ta blokada obowiązuje tego dnia" istniał w trzech kopiach (findMatchingClosure,getClosureMinuteRangesi nowa funkcja) – jest teraz jeden,closureAppliesToDate. -
Terminy serii generuje
generateRecurringGameOccurrencesShared(lib/utils/recurring-occurrences.ts) – pomija dni wolne i zamknięcia kortu, raportuje je wskippedDates(kind: 'holiday' | 'closure') i liczy godziny w strefieEurope/Warsaw. Ten sam wynik zasila sprawdzanie konfliktów (useRecurringConflicts) i zapis, także przy „pomiń konflikty”. -
cancelReservationprzyjmuje flagirefundToWalletiskipNotification; odwołanie serii idzie przezdeleteRecurringGameSeries. -
Rozliczenie:
settlePaymentInClub(gotówka/karta/portfel, karty benefitowe jako lista{type, amount, count}, 15 zł za sztukę) orazmarkPaymentAsArrearsdla przycisku Zaległość. -
Po utworzeniu i po zmianie rezerwacji wywoływane jest
syncBookingToHardware, które przekazuje termin i PIN do kontroli dostępu.
Cena niestandardowa a stawka indywidualna
booking.custom_price pełni dwie role naraz: przechowuje ręczne nadpisanie ceny
i – dla klienta ze stawką z client_settings – kwotę wyliczoną z tej stawki,
zapisaną przy tworzeniu rezerwacji (potrzebują jej widoki, które nie liczą cennika
same: BookingDetails, ReservationsClient, historia zmian).
EditReservationForm rozróżnia te dwa przypadki przez
storedCustomPriceMirrorsClientRate: zapisana kwota jest uznawana za wyliczoną,
gdy odpowiada calculateActivityPrice(effectiveActivityType, ...) dla
pierwotnego terminu i różni się od ceny bazowej cennika. Tylko wtedy pole jest
odświeżane przy każdej zmianie godzin – inaczej skrócenie lub wydłużenie
rezerwacji zostawiałoby starą kwotę i taka trafiałaby do payment.amount przez
applyReservationTotalPriceChange. Ręczne nadpisanie (oraz każda edycja pola przez
pracownika – dirtyFields.custom_price) nie jest ruszane.
Rezerwacje zepsute przed tą poprawką zostały naprawione jednorazowo na bazie
klient 05.09.2026: booking 427/428/429 → 300 zł i booking 442 → 150 zł wraz z
game.custom_price oraz płatnościami 35965/35966/35967 → 300 zł i 35980 → 150 zł
(wszystkie były w statusie pending, więc nie było zwrotów ani dopłat do
wystawionych dokumentów). Skrypt audytowy i wygenerowany z niego SQL były
jednorazowe i nie są trzymane w repozytorium.
Gdyby audyt trzeba było powtórzyć: cenniki produkcyjne są advanced, więc ceny nie
policzy się w SQL – trzeba wziąć zrzuty z D1 i wyliczyć kwoty w TS tą samą funkcją
calculateActivityPrice, której używa aplikacja (z TZ=UTC). Odcisk palca tego
błędu to: zapisana cena równa się kwocie policzonej dla pierwotnego terminu z
wpisu game_created w game.event_log, a termin się od tego czasu zmienił.
Poprawka to UPDATE na booking.custom_price, game.custom_price i
payment.amount, każdy ze starą wartością w WHERE, żeby dało się go bezpiecznie
powtórzyć.
Uprawnienia
Formularz pokazuje pola Cena niestandardowa, Notatki i sekcję
Uczestnicy tylko rolom ADMIN i BACKOFFICE (useRole). Klient w tym samym
oknie widzi wyłącznie kort, termin i cenę.