Skip to main content

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​

  1. Kliknij wolne pole w siatce, na korcie i o godzinie, o którą prosi klient.
  2. 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.
  3. 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).
  4. 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.

  1. Sprawdź kwotę do zapłaty (pole z imieniem i nazwiskiem możesz poprawić, np. na potrzeby paragonu).
  2. 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.
  3. Zaznacz Drukuj paragon, jeśli klient chce paragon.
  4. 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​

SytuacjaCo zrobić
Klient chce przesunąć godzinęSzczegóły → Edytuj → zmień godziny → Zaktualizuj
Klient chce inny kortSzczegół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śniejOdwołaj → Odwołaj całą serię (odwoła przyszłe terminy)
Klient chce przenieść cały cykl na inny kortSzczegóły → Edytuj → Zakres edycji: Ta i wszystkie kolejne → zmień Kort
Nie widać wolnego terminu, choć kort pustySprawdź miasto/lokalizację na górze i godziny otwarcia kortu (przycisk Godziny otwarcia)

🛠️ Dokumentacja techniczna​

Ścieżki i komponenty​

ElementPlik
Kalendarz recepcjiapp/(dashboard)/dashboard/schedule (BookingClient.tsx)
Formularz nowej rezerwacjiapp/(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 cykluReservationSeriesScope.tsx, RecurringConflictWarning.tsx
Szczegóły rezerwacjicomponents/schedule/reservation-details-dialog.tsx
Rozliczenie / płatność mieszanareservation-settle-dialog.tsx, reservation-advanced-payment-dialog.tsx
Odwołaniecomponents/schedule/reservation-cancel-dialog.tsx
Lista rezerwacji dla pracownikaapp/(dashboard)/dashboard/history (/reservations-overview przekierowuje tutaj)

Logika serwerowa​

  • createAdminReservation (lib/actions/booking.ts) – tworzy grę, booking, opcjonalną płatność i wysyła powiadomienia. Gdy immediate_payment_employee (lub indywidualne require_online_payment klienta) jest włączone, rezerwacja i płatność dostają expires_at = teraz + employee_payment_time minut, a gra powstaje bez płatności (createGameWithoutPayment).

  • Opcje płatności z formularza nadpisują ustawienia klubu. createAdminReservation i generateRecurringReservations przyjmują require_immediate_payment i send_payment_link. Pierwsza trafia do requiresImmediateCourtPayment jako employeeOverride i stoi wyżej niż require_online_payment klienta i immediate_payment_employee lokalizacji, 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 sesji ADMIN/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ść (findSettleableGamePaymentId dla jednorazowej, generatedGames[0].paymentId dla serii – w obu przypadkach wystawił ją już createGame/generateRecurringGames), zakłada payment_link wygasający w chwili rozpoczęcia rezerwacji (expiryMinutes = minuty do start_time, dla serii do startu pierwszego terminu) i wysyła SMS reservation_payment_link_optional. Ten sam token trafia do payUrl w 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_texts są 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 uruchamia 0274 na tekstach zasianych przez 0218/0245 i porównuje wynik z messages/*.json.

  • Nadpisanie z formularza jedzie tylko wtedy, gdy ktoś ruszył przełącznik. paymentPolicy dociera 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łoby allow_arrears i require_online_payment tego klienta, więc formularz posyła null, 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_id zaczynające się od local|) oznacza, że jedynym kanałem jest SMS. Dlatego player_added_to_reservation i player_added_to_recurring_reservation niosą warunkowe {{payUrl}} (migracja 0274, 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, wzorem 0198; dopasowanie do całej wiadomości nie zadziała, bo brzmienie różni się między tenantami), a reservation_payment_link_optional jest 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łę smsChannelCarries z 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ła reservation_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ż okno RESERVATION_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 samo totals.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.ts wysyła wyłącznie przez templateId (szablony dynamiczne), a mails/*.html to 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 SendGrida d-6249ddecec444103908dcab96f1e9c0f (rezerwacja jednorazowa) i d-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 calculateActivityPrice z uwzględnieniem reguł cenowych i nadpisań klienta (applyClientPricingOverride); custom_price z 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) zwraca false dla ceny <= 0, niezależnie od ustawień lokalizacji, nadpisania klienta i zaległości. createPayment rozlicza zerową kwotę już przy INSERT (status paid), więc żadna późniejsza zmiana statusu nie nadejdzie, a clearGameExpiresAt / 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 przez createGame, uczestnik nie jest draft, booking_expires_at zostaje 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: applyReservationTotalPriceChange tylko przepisuje kwotę i zostawia płatność jako pending, a linku na 0 zł nie da się opłacić (/api/public/payment/initialize odrzuca 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ę przez clearGameExpiresAt + 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 osobnego handlePriceChangeForPayments, który porównywał nową cenę z każdym wierszem osobno i zawyżał rachunek podzielony w recepcji. Teraz zajęcia korzystają z tego samego planTotalPriceChange, tylko osobno dla każdego uczestnika (docs/docs/payments.md, „Przeliczenie ceny jako jedna paczka"). applyReservationTotalPriceChange bierze newTotal jako 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 na cancelled, a nadpłatę zwraca do portfela idąc od najnowszej wpłaty wstecz. Wiersze cancelled, refunded i expired są pomijane. Otwarty wiersz zostaje z kwotą 0 (zamiast być anulowany), gdy wyjdzie zero – tego wiersza potrzebuje settleHoldOnReservationEditedToFree, żeby zdjąć hold z rezerwacji przecenionej do zera. Za „rozliczone" uznaje isSettledPaymentStatus (lib/utils/payment-status.ts), a więc pełną listę SETTLED_PAYMENT_STATUSES razem z paid_split i paid_split_partial. Prywatne isPaidStatus w payment.ts tych 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 payments z getGamesByCity, 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. Wiersze cancelled, refunded i expired nadal 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 z getGamesByCity szły przez payment_related_game złą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 tablicy payments, za wierszami miejsc: znaczniki przy nazwiskach dalej czytają swój wiersz po indeksie (findSeatPayment dopasowuje po id/player_id), a pytanie „czy cały slot jest rozliczony" widzi już cały rachunek. Wiersze cancelled, refunded i expired odsiewa SQL, tak samo jak pending z wygasłym holdem (payment_expires_at w przeszłości) – nic nie są winne, a getReservationDetails liczy 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.payment to wiersz rezerwującego, nie wiersz do rozliczenia. Pierwsze zapytanie getReservationDetails zawęża LEFT JOIN payment do b.booked_for_player_id, więc gdy rezerwujący zapłacił swoją część, a druga osoba jeszcze nie, ReservationSettleDialog brał jego opłacony wiersz jako activePayment, wpadał w gałąź isPaid i pokazywał „płatność już rozliczona" – mimo że w szczegółach stało Pozostało do zapłaty 20 zł i przycisk Rozlicz był aktywny. getReservationDetails oddaje teraz osobne outstandingPayment: pierwszy nierozliczony wiersz rezerwacji (isSettledPaymentStatus) razem z płatnikiem, do którego należy (LEFT JOIN player po pay.player_id daje imię, nazwisko i owner_email). Okno rozliczenia bierze go przed details.payment, więc kwota, nazwisko na paragonie, portfel, zaległość i dane do faktury dotyczą tej osoby, która faktycznie płaci. details.payment zostaje bez zmian, bo z niego liczy się komunikat o zwrocie przy odwoływaniu rezerwacji. Zajęcia grupowe wybierają uczestnika przez attendeePayments, więc tam outstandingPayment jest 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 wiersz paid_split_partial mówi sam za siebie – jego odroczona część siedzi w linked_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. getLivePaymentsForAttendee czytała wyłącznie prg.player_id = ?, więc po podziale rachunku recepcja widziała przy nazwisku jedną połowę i ani śladu drugiej – PaymentDetailsDialog dostawał jednoelementowe siblings i nie pokazywał nawet ramki z sumą. Zapytanie dokłada teraz (tylko dla payment_type = 'court_reservation') wiersze wiszące na graczu, którego nie ma w game.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, MarkAsPaidButton bierze płatnika z wiersza, a nie z miejsca (imię na paragon, portfel, zaległość), a efekt stemplujący attendee.paymentId pomija wiersze, które do miejsca nie należą.

  • Podział nie dziedziczy holdu rezerwacji. extractPaymentToNewPlayer kopiowało payment_expires_at z 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 sweep expirePendingPayments przestawiał dług na expired i 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 migracja 0271 czyści go na istniejących wierszach pending. Wierszy już przestawionych na expired migracja 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, czyli teraz + 60 sekund (getPaymentDueDateForGame), i taki termin zostaje zapisany na wierszu przy każdej zmianie ceny. Minutę później jest już przeszłością – a createPayment odrzucał 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. Dlatego createPayment przyjmuje datesInheritedFromPaymentId: wskazanie wiersza źródłowego wyłącza kontrolę „data musi być w przyszłości" dla dueDate i paymentExpiresAt (assertDueDateIsAcceptable, assertPaymentExpiryIsAcceptable w lib/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, nie SETTLED_PAYMENT_STATUSES. Podział rozliczony przy ladzie z odroczoną częścią zostawia na wierszu nadrzędnym paid_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ł po PAID_PAYMENT_STATUSES z lib/analytics/payments.ts i pokazywał zielono. Obie ścieżki czytają teraz FULLY_PAID_PAYMENT_STATUSES z constants/data.ts – SETTLED_PAYMENT_STATUSES bez obu statusów „rozliczone częściowo": paid_split_partial i paid_partial_wallet. Oba PaymentStatus rysuje 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/payments o 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=true oddaje teraz komplet przez getLivePaymentsForAttendee (nieuregulowane pierwsze, bez wierszy cancelled/refunded i 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: PaymentDetailsDialog dostaje siblings i 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 jego player_id i user_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. createPaymentsForRecurringSeries zapisuje [gameId, seriesId], a recurring_game_series.id dzieli przestrzeń numerów z game.id — dopasowanie „gdziekolwiek" doklejało płatności serii do zajęć o numerze równym numerowi serii (stąd migracja 0261 i tabela payment_related_game). Przy sumowaniu wierszy taka kolizja nie tylko myli widok, ale zawyża settledTotal i wystawia złą dopłatę. getLivePaymentsForAttendee czyta przez indeksowaną payment_related_game, pozostałe przez json_extract(related_ids, '$[0]').

  • getReservationDetails zwraca obok pojedynczej płatności (payment, dalej używanej przez okna rozliczenia i odwołania) także payments – wszystkie aktywne wiersze gry – oraz totals (price, settled, outstanding). Wcześniej okno szczegółów pokazywało jako „cenę" jeden wiersz wybrany przez LIMIT 1 z preferencją dla pending, więc rezerwacja o kilku wierszach pokazywała przy Rozlicz kwotę inną niż w edycji. Obie kwerendy idą jednym db.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 strefie Europe/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 jedno db.batch z 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 }; dopiero skipConflicting: true przenosi tylko wolne terminy. Zapisy idą partiami: jedno db.batch na wiersze game (kort, godziny, uczestnicy, wpis do event_log przez json_insert), drugie na wiersze booking, trzecie na płatności. Wiersz recurring_game_series też 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 powodu custom_price, internal_notes, applied_pricing_rule_id i booked_for_player_id idą przez CASE 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 powiadomienie reservation_moved / reservation_court_changed opisują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 z updateReservation przez wspólne notifyReservationChanged.

  • 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łędem COURT_SCHEDULE_OVERLAP. custom_price (tylko dla ADMIN/BACKOFFICE) trafia do kolumny custom_price każdej gry, do wiersza booking i do kwoty płatności.

  • Hold serii: gdy requiresImmediateCourtPayment zwróci true, generateRecurringReservations zapisuje termin blokady w game_expires_at każdej gry, booking_expires_at każdego wiersza booking, w polach draft/paymentStartedAt/paymentExpiresAt uczestnika oraz w payment_expires_at każdej płatności. Link do płatności idzie tylko dla pierwszego terminu (sendReservationNotifications), a potwierdzenie tej płatności zwalnia całą serię przez releaseRecurringCourtSeriesHold (lib/series-reservation-hold-sql.ts) wpięte w clearGameExpiresAt – 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ść wygasza expirePendingPayments, 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 = expirePendingPayments wygasza płatności, a leniwe filtry na game_expires_at zwalniają korty. Kształt skopiowany z addPlayerToRecurringSeries (zapis na zajęcia stałe).

  • generateRecurringGames (lib/actions/game.ts) z enforceCourtAvailability: true wstawia każde wystąpienie zapytaniem INSERT ... WHERE NOT EXISTS, tak jak createGame. 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, szablon mails/recurring-reservation-confirmation.html, migracja 0242). Wysyła je generateRecurringReservations po utworzeniu bookingów – dopiero wtedy znany jest PIN serii. generateRecurringGames dostaje sendNotification: false z 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 (konto local, adres bez skrzynki) – SMS-em, tak samo jak przy jednorazowej. Przy aktywnym holdzie zamiast potwierdzenia idzie SMS z linkiem, a po zapłacie booking_payment_success.

  • Zamknięcia i blokady kortu (court.closures) sprawdza findClosureForInstant (lib/utils/opening-hours.ts) – godzinę i dzień czyta w strefie Europe/Warsaw, więc Worker w UTC widzi to samo, co kalendarz. Serwer odrzuca zamknięty termin błędem COURT_CLOSED przy zakładaniu rezerwacji (klienckiej i przy ladzie), przy edycji cyklu, przy generowaniu terminów serii oraz przy edycji pojedynczej rezerwacji. Blokady nie są wierszami w game, 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.

  • updateReservation poró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 strefie Europe/Warsaw i 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, getClosureMinuteRanges i nowa funkcja) – jest teraz jeden, closureAppliesToDate.

  • Terminy serii generuje generateRecurringGameOccurrencesShared (lib/utils/recurring-occurrences.ts) – pomija dni wolne i zamknięcia kortu, raportuje je w skippedDates (kind: 'holiday' | 'closure') i liczy godziny w strefie Europe/Warsaw. Ten sam wynik zasila sprawdzanie konfliktów (useRecurringConflicts) i zapis, także przy „pomiń konflikty”.

  • cancelReservation przyjmuje flagi refundToWallet i skipNotification; odwołanie serii idzie przez deleteRecurringGameSeries.

  • Rozliczenie: settlePaymentInClub (gotówka/karta/portfel, karty benefitowe jako lista {type, amount, count}, 15 zł za sztukę) oraz markPaymentAsArrears dla 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ę.