Przejdź do głównej zawartości

Przeniesienie cyklu zajęć (Zmiana grupy stałej)

Funkcjonalność „Przenieś” (w panelu recepcji: „Zmień termin zajęć”) umożliwia klientom oraz recepcji przeniesienie całego zapisu na zajęcia stałe (cykl / recurring_game_series) do innej grupy — klientowi na tym samym poziomie zaawansowania, recepcji także na inny poziom.

System w pełni automatycznie i transparentnie rozlicza różnice w cenach zajęć (nadpłaty oraz niedopłaty) dla bieżącego opłaconego okresu, nie wymagając ręcznych korekt księgowych.


👤 Instrukcja dla użytkownika i recepcji​

1. Gdzie znajduje się opcja „Przenieś”​

Przycisk „Przenieś” znajduje się w oknie szczegółów zajęć (Szczegóły zajęć), które otwiera się po kliknięciu w dowolne przyszłe zajęcia w kalendarzu uczestnika (/dashboard/user-activities/[playerId]).

[!NOTE] Przycisk pojawia się wyłącznie dla zajęć wchodzących w skład cyklu stałego (recurring_game_series). Dla pojedynczych rezerwacji, zajęć próbnych lub zajęć z przeszłości opcja jest niedostępna.

+-------------------------------------------------------------+
| Szczegóły zajęć X |
| Danielllll Kochanek |
| |
| [ Kort 1 ] [ sobota, 15 sierpnia ] [ 21:30-22:30 ]|
| [ AKTYWNOŚĆ ] [ INSTRUKTOR ] [ STATUS ] |
| |
| [ Anuluj ] [ Przenieś ] [ Odwołaj zajęcia]|
+-------------------------------------------------------------+

2. Krok po kroku: Jak przenieść cykl​

  1. Kliknij w zajęcia w swoim kalendarzu, aby otworzyć okno Szczegóły zajęć.
  2. Kliknij przycisk „Przenieś”.
  3. Otworzy się okno „Przenieś cykl zajęć”, w którym widzisz:
    • Podsumowanie obecnego cyklu: nazwę grupy, dzień i godzinę, kort, liczbę pozostałych zajęć w cyklu oraz wartość już opłaconych zajęć.
    • Listę dostępnych nowych grup: grupy w Twoim przypisanym poziomie z wolnymi miejscami (dzień tygodnia, godziny, trener, kort, liczba wolnych miejsc i cena).
  4. Wybierz nową grupę, do której chcesz przenieść uczestnika.
  5. System natychmiast wyświetli Rozliczenie finansowe:
  1. Kliknij „Potwierdź i przenieś” (lub „Przenieś i opłać”, jeśli wymagana jest dopłata online). System automatycznie zaktualizuje Twój kalendarz i rozliczenia, a przy dopłacie online od razu przekieruje Cię do zakładki Płatności.

2a. Zmiana terminu z panelu recepcji (AP-1034)​

Recepcja nie potrzebuje już usuwać uczestnika z grafiku i zapisywać go od nowa — w oknie edycji zajęć (Grafik ➔ zajęcia ➔ edycja) każdy wiersz uczestnika grupowych zajęć cyklicznych ma w menu „⋯” pozycję „Zmień termin zajęć”. Otwiera ona to samo okno przeniesienia, które widzi klient, z czterema różnicami:

  1. Wszystkie grupy w wybranym mieście — także inne poziomy niż przypisany uczestnikowi. Lista ma filtr poziomów, a przy wyborze grupy z innego poziomu pojawia się ostrzeżenie. Klient nadal widzi wyłącznie swój poziom.
  2. Można przenieść ponad limit miejsc — pełna grupa jest wybieralna, ale wymaga przełączenia „Przenieś ponad limit miejsc”. Fakt przekroczenia limitu zapisuje się w historii zajęć, bo liczniki wolnych miejsc w pozostałych widokach (baner zapisu, publiczne zapisy) liczą się z listy uczestników i nie mówią nic o ręcznej decyzji. Klientowi pełna grupa pozostaje niedostępna.
  3. Bez przekierowania do płatności — gdy po zmianie zostaje dopłata, recepcja dostaje kwotę w komunikacie i zostaje w grafiku; do zakładki Płatności przekierowywany jest tylko klient, który tę dopłatę reguluje.
  4. Okno edycji zajęć zamyka się od razu po przeniesieniu, a grafik się odświeża. Formularz trzyma listę uczestników wczytaną przed zmianą — gdyby został otwarty, kliknięcie „Zaktualizuj” zapisałoby przeniesionego uczestnika z powrotem do starych zajęć.

Miasto, z którego brane są grupy, pochodzi z globalnego selektora lokalizacji w nagłówku panelu (z zapasem na miasto uczestnika). Dla klienta parametr miasta z adresu jest ignorowany — jego lista zawsze wynika z miasta uczestnika.

Okno mówi wprost, czego się nie da zrobić:

KomunikatZnaczenie
Uczestnik nie ma przypisanego żadnego poziomu…Brak wpisu w player_activity_types; przypisz poziom w profilu uczestnika.
W obecnym cyklu nie ma już zajęć przed nami…Wszystkie zajęcia cyklu są za nami — nie ma czego przenosić, trzeba użyć zwykłego zapisu.
Regulamin dla tego zapisu nie został jeszcze zaakceptowany…Recepcja zapisała uczestnika, a klient nie kliknął jeszcze linku z SMS-a. Miejsce czeka na akceptację (statuteExpiresAt przy uczestniku) i nie da się go przenieść, dopóki regulamin nie zostanie zaakceptowany albo zapis nie wygaśnie.
Brak innych dostępnych grup…Są poziomy i terminy, ale żaden nie nadaje się na cel (brak miejsc u klienta, brak przyszłych terminów).

Nad podsumowaniem obecnego cyklu widać też plakietkę „Zrealizowane w tym miesiącu: N zajęć” — to liczba zajęć, które uczestnik już odbył w starej grupie w bieżącym miesiącu. Rozliczenie jej nie dotyczy (te zajęcia zostają opłacone tam, gdzie się odbyły); plakietka jest po to, żeby recepcja widziała, skąd wynika kwota.


3. Zasady rozliczania finansowego (Nadpłata i Niedopłata)​

Rozliczenie w momencie przenoszenia dotyczy wyłącznie bieżącego, opłaconego okresu (miesiąca):

[!IMPORTANT] Okno rozliczenia to reszta bieżącego miesiąca kalendarzowego i nic więcej (TRANSFER_BILLING_MIN_SESSIONS = 0). Zwykły zapis ma regułę minimalnej liczby zajęć w pierwszym miesiącu (trial_conversion_min_first_month_sessions, domyślnie 2), która przy zapisie pod koniec miesiąca przesuwa okno na kolejny pełny miesiąc — żeby nikt nie kupował „pierwszego miesiąca” złożonego z jednych zajęć. Przy zmianie terminu ta reguła dawała efekt odwrotny do zamierzonego: klient, który zmieniał dzień w ostatnim tygodniu miesiąca, dostawał do zapłaty resztę miesiąca plus cały kolejny, choć kredytem były tylko niewykorzystane zajęcia ze starej grupy. Kolejne miesiące powstają normalnie jako pending z terminem płatności z nowej grupy.

A. Nowa grupa jest tańsza (Nadpłata)​

  • Jeśli za pozostałe zajęcia w bieżącym miesiącu zapłacono więcej niż wynosi koszt nowej grupy, powstaje nadpłata.
  • Nadwyżka środków zostaje automatycznie zwrócona do Portfela klienta w aplikacji (wallet).
  • Środki z portfela można wykorzystać na przyszłe opłaty, rezerwacje kortów lub inne usługi.
  • Wszystkie zajęcia w nowej grupie w bieżącym miesiącu zostają oznaczone jako w pełni opłacone.

B. Nowa grupa jest droższa (Niedopłata / Wymagana dopłata)​

  • Jeśli nowa grupa ma wyższą stawkę za zajęcia w bieżącym miesiącu, powstaje różnica do dopłaty.

  • Opcja 1 (Płatność z Portfela): Jeśli klient posiada wystarczające środki w portfelu, może włączyć przełącznik „Opłać dopłatę ze środków w portfelu” — kwota zostanie natychmiast pobrana, a zajęcia oznaczone jako opłacone (przycisk ma treść „Potwierdź i przenieś”).

  • Opcja 2 (Płatność online — Przenieś i opłać): Jeśli środki w portfelu są niewystarczające lub przełącznik jest wyłączony, przycisk zmienia treść na „Przenieś i opłać”. Po zatwierdzeniu cykl zostaje przeniesiony, a klient zostaje natychmiast przekierowany do widoku /dashboard/payments w celu szybkiego uregulowania różnicy online (BLIK/P24). Przekierowanie niesie miasto nowej grupy (?city=…, pole paymentCity w odpowiedzi przeniesienia): lista Płatności bez miasta w adresie bierze miasto z konta klienta, więc klient z innym miastem na koncie widział pustą listę, choć płatność czekała.

  • Reszta wpłaty, która nie pokrywa pełnych zajęć, wraca do Portfela. Przeniesione środki opłacają zajęcia nowej grupy tylko w całości (tylko wtedy nowa płatność może przejąć paragon). Jeśli po opłaceniu pełnych zajęć zostaje reszta — np. 40 zł wobec zajęć po 80 zł — trafia ona do Portfela jako transakcja „Zwrot niewykorzystanej wpłaty po przeniesieniu cyklu do: …”, a zajęcia zostają do zapłaty w pełnej kwocie. Wcześniej taka reszta przepadała: stara płatność była oznaczana jako zwrócona (funds_retained = 1), ale kwota nie opłacała niczego i nie trafiała do portfela. Wynik przeniesienia zwraca ją w polu returnedToWallet.

C. Co z kolejnymi miesiącami cyklu?​

  • Przyszłe, jeszcze nieopłacone miesiące ze starej grupy zostają automatycznie anulowane.
  • W ich miejsce generowane są nowe płatności ze stawką nowej grupy.
  • Klient opłaca je standardowo co miesiąc w zakładce Płatności, zgodnie z terminem płatności.
  • Termin płatności bierze się z nowej grupy — z payment_due_type / payment_due_value jej rodzaju zajęć, dokładnie tak jak przy zwykłym zapisie na cykl (getPaymentDueDateForGame). Wcześniej płatności z przeniesienia powstawały bez terminu: nie dało się ich na czas nazwać zaległymi, a wiersz miesiąca w panelu pożyczał termin od innej, często już opłaconej pozycji i świecił się na czerwono bez powodu. Zajęcia pokryte przeniesionymi środkami są od razu opłacone, więc terminu nie dostają.

D. Gdzie szukać przeniesionych płatności​

  • Płatności z przeniesienia trafiają do lokalizacji nowej grupy — miasta i ulicy kortu, na którym odbywają się nowe zajęcia.
  • Ma to znaczenie w panelu: lista Płatności filtruje po wybranym mieście, więc płatność zapisana pod złym miastem po prostu nie pojawia się na liście, mimo że w kalendarzu zajęcia widnieją jako nieopłacone.
  • Wcześniej płatności z przeniesienia zapisywały się pod miastem domyślnym (Opole), przez co np. klientka z Lublina, która przeniosła się z czwartku na środę, miała w Płatnościach tylko opłacony wrzesień ze starej grupy, a dodatkowe (piąte) zajęcia środowe nie wyświetlały się nigdzie poza kalendarzem. Migracja 0270_fix_transferred_payment_location.sql naprawia dane zapisane przed poprawką.

E. Paragon (lub faktura) po przeniesieniu​

  • Przeniesienie nie jest nową sprzedażą — te same pieniądze zmieniają jedynie zajęcia, na które są zaksięgowane. Klub nie wystawia więc drugiego paragonu.
  • Dokument wystawiony przy pierwotnej wpłacie wędruje razem z pieniędzmi: nowe zajęcia w zakładce Płatności pokazują ten sam numer paragonu i ten sam link do e-Paragonu, co zajęcia w starej grupie.
  • Kwota, której przeniesione środki nie pokrywają (dopłata przy droższej grupie, kolejne miesiące), zostaje jako do zapłaty i otrzyma własny dokument dopiero w chwili faktycznej wpłaty.

🛠️ Dokumentacja techniczna dla programistów​

1. Architektura i diagram przepływu​


2. Moduły i pliki źródłowe​

PlikRola i odpowiedzialność
lib/recurring-transfer.tsRdzenna logika biznesowa: pobieranie opcji przeniesienia (loadTransferOptions), walidacja miejsc, atomowa zmiana uczestnika w grach i kalkulacja finansowa (executeRecurringTransfer).
app/api/recurring-series/transfer/route.tsEndpoint HTTP GET oraz POST z obsługą rate limitera (20 req/min), weryfikacji sesji Better Auth i tokenu CSRF. GET przyjmuje city, POST dodatkowo city i allowOverbook — oba traktowane jako żądanie, nie uprawnienie.
app/(dashboard)/dashboard/schedule/components/AttendeeInfo.tsxWejście dla recepcji: pozycja „Zmień termin zajęć” w menu „⋯” wiersza uczestnika w oknie edycji zajęć. recurring_series_id wędruje tu z EditGameForm przez EditGameFormAttendees i AttendeesList.
components/forms/recurring-series/transfer-cycle-dialog.tsxKomponent UI okna dialogowego z dynamicznym kalkulatorem nadpłat/niedopłat w czasie rzeczywistym.
app/(dashboard)/dashboard/user-activities/[playerId]/components/UserActivities.tsxIntegracja przycisku „Przenieś” w oknie szczegółów zajęć na desktopie.
app/(dashboard)/dashboard/user-activities/[playerId]/components/MobileActivitiesList.tsxIntegracja przycisku „Przenieś” w oknie szczegółów zajęć w widoku mobilnym.

3. Modele danych i powiązania​

  • game.attendees: Tablica JSON [{ id: playerId }]. Podczas transferu uczestnik jest usuwany z gier starej serii i dodawany do gier nowej serii.
  • game.event_log: Rejestrowane są wpisy zdarzeń transferred_out (ze wskazaniem ID nowej serii) oraz transferred_in (ze wskazaniem ID starej serii).
  • payment:
    • Płatności powiązane z serią są dopasowywane przez related_ids[0] (ID zajęć) do gier o danym recurring_series_id. createPaymentsForRecurringSeries zapisuje [gameId, seriesId], ale płatności utworzone przy dopisaniu uczestnika do istniejącej serii z panelu mają samo [gameId] — dopasowanie po samym related_ids pomijało je, przez co opłacone zajęcia nie były zwracane, a klient płacił drugi raz w nowej grupie.
    • Płatności za przyszłe miesiące posiadają flagę monthly_only = 1.
    • payment.city / payment.street biorą się z kortu serii docelowej — tej samej reguły pilnuje resolvePaymentLocation() w lib/actions/payment.ts dla pozostałych ścieżek zapisu. executeRecurringTransfer wstawia płatności własnym INSERT-em (bez createSystemPayment), więc musi ustawić te kolumny jawnie; pominięte wpadały w domyślne wartości schematu ('Opole' / 'Spokojna') i znikały z listy Płatności filtrowanej po mieście.
    • Stare opłacone płatności przechodzą w stan status = 'refunded' z funds_retained = 1, co odzwierciedla przetransferowanie środków do nowego cyklu.
    • Nowe płatności opłacone z przeniesionych środków dziedziczą invoice_id / invoice_number / receipt_id / receipt_number / e_receipt_view_url po zatrzymanych płatnościach źródłowych. Bez tego przeniesiona kwota wyglądałaby na niezafiskalizowaną sprzedaż i trafiłaby do raportu lib/missing-documents-report.ts, a wystawienie z tego raportu zdublowałoby paragon.
    • Przydziałem dokumentu zajmuje się takeCarriedDocument(): zatrzymane środki tworzą kolejkę konsumowaną w kolejności wstawiania nowych płatności. Płatność dziedziczy dokument tylko gdy przeniesione środki pokrywają ją w całości i bierze referencję źródła, które pokryło jej największą część. Reszta (np. część finansowana z portfela przy dopłacie) zostaje bez dokumentu.
  • wallet_transaction:
    • W przypadku nadpłaty tworzona jest transakcja typu credit zwiększająca saldo portfela klienta.
    • W przypadku dopłaty z portfela tworzona jest transakcja typu debit.

3a. Co wolno komu (walidacja po stronie serwera)​

Lista kandydatów powstaje w jednym miejscu — resolveTransferCandidates() — i tej samej listy używa zarówno GET (co pokazać w oknie), jak i POST (co wolno zapisać). Wcześniej zapis przyjmował dowolne toSeriesId z treści żądania: opiekun mógł przenieść dziecko do grupy o poziomie, którego nikt mu nie przypisał, a nawet w innym mieście. Dziś taki cel kończy się błędem target_series_not_allowed.

RegułaOpiekunRecepcja (ADMIN / BACKOFFICE)
Zakres poziomówtylko player_activity_types uczestnikawszystkie grupy (levelIds: 'any')
Miastomiasto uczestnika → miasto sesji → DEFAULT_CITY; city z żądania ignorowanecity z żądania (globalny selektor), z tym samym zapasem
Pełna grupaniedostępna (target_series_full)dostępna przy allowOverbook, wynik wraca z overbooked: true
Brak przypisanego poziomustate: 'no_levels', pusta listalista buduje się normalnie

Przy przekroczeniu limitu zajęcia docelowe dostają zdarzenie transferred_in_over_limit (z polem overbooked: true) zamiast zwykłego transferred_in, z własną etykietą i opisem w historii zajęć („… ponad limit miejsc”). Osobny typ zamiast parametru w opisie jest celowy: zdarzenia transferred_in zapisane wcześniej nie mają dodatkowych parametrów, a opis z wyborem po brakującym parametrze nie dałby się dla nich sformatować. Opisy transferred_in / transferred_out wcześniej nie istniały w messages/*.json, więc historia pokazywała w tych miejscach surowy klucz.

3a-bis. Blokada przy niezaakceptowanym regulaminie​

Zapis zrobiony przez recepcję czeka na akceptację regulaminu z linku w SMS-ie (patrz Akceptacja regulaminu po zapisie przez recepcję). Do tego czasu każde zajęcia cyklu mają przy uczestniku pole statuteExpiresAt. Przeniesienie takiego miejsca obeszłoby akceptację — nowa grupa dostałaby uczestnika bez zgody na regulamin, a cron wygaszający nie znalazłby już zajęć, które miał zwolnić.

  • loadTransferOptions zwraca wtedy state: 'statute_pending'; okno pokazuje komunikat i blokuje zatwierdzenie.
  • executeRecurringTransfer odrzuca zapis błędem statute_not_accepted, zanim cokolwiek zmieni — sprawdzenie stoi po stronie serwera, bo endpoint przyjmuje żądanie wprost.
  • Warunkiem jest znacznik przy uczestniku w przyszłych zajęciach starej serii, a nie status wiersza statute_acceptance_request: znacznik znika w chwili akceptacji, a wygasły, jeszcze nieprzetworzony przez cron wniosek dalej oznacza niezaakceptowany regulamin.

3b. Powiadomienie o zmianie terminu​

  • Każde przeniesienie — z panelu recepcji i z kalendarza klienta — wysyła jedno powiadomienie recurring_term_changed: SMS i push, bez e-maila (nie ma szablonu w SendGridzie).
  • Treść: nowy dzień i godzina, kort, data pierwszych zajęć i kod PIN do bramki. Zdanie „Do dopłaty … zł” z linkiem do Płatności pojawia się tylko wtedy, gdy po zmianie zostało coś do zapłaty — dopłata pobrana od razu z portfela się nie liczy.
  • Potwierdzenie zapisu player_added_to_recurring_series („Zapisano Cię na cykliczne zajęcia…”) przy przeniesieniu nie wychodzi wcale, ani SMS-em, ani mailem. Wcześniej klient dostawał je obok komunikatu o zmianie terminu i wyglądało to jak dwa osobne zapisy.
  • Wyłączenie SMS-a tego powiadomienia w panelu oznacza, że klient nie dostanie SMS-a o zmianie terminu — nie ma już zapasowego potwierdzenia zapisu.

Konfiguracja: lib/notification-config.ts, migracja 0277_add_recurring_term_changed_notification.sql, treści zapasowe: messages/pl.json / messages/en.json → notifications.recurring_term_changed.

3c. Cena zajęć, rozbicie dopłaty i godziny w oknie​

  • Cena przy obecnym cyklu to koszt najbliższych zajęć tej grupy liczony przez calculateActivityPrice (z ceną indywidualną klienta), a nie activity_types.price. Cena rodzaju zajęć jest stawką godzinową: 30-minutowe zajęcia typu za 80 zł kosztują 40 zł. Okno pokazywało wcześniej „80 zł / zajęcia” przy obu grupach, więc dopłata wynikająca z dłuższych zajęć w nowej grupie wyglądała na błąd.

  • Dopłata jest rozbita (splitShortfall w lib/recurring-transfer-summary.ts) na:

    • nieopłacone zajęcia starej grupy w rozliczanym okresie — pieniądze, które i tak były do zapłaty (kredytem są wyłącznie środki faktycznie wpłacone, płatności pending zostają anulowane),
    • różnicę cen między grupami w tym okresie (może być ujemna — wtedy „tańsza o …”).

    Przykład: klient zapłacił tylko pierwsze z trzech wrześniowych zajęć środowych (30 min, 40 zł), a przenosi się na sobotę (60 min, 80 zł, dwa terminy do końca września). Dopłata 160 − 40 = 120 zł to 80 zł za dwie nieopłacone środy i 40 zł realnej różnicy cen. Okres liczony jest do końca miesiąca ostatnich rozliczanych zajęć nowej grupy (first_payment_until), w strefie Europe/Warsaw.

  • Godziny podaje seriesClockTime: recurring_game_series.start_time bywa gołą godziną („17:00”, starsze serie) albo pełnym znacznikiem czasu („2026-09-16T07:00:00.000Z”, serie założone w panelu). Obcinanie pierwszych pięciu znaków drukowało w drugim przypadku „2026-”.

  • Licznik nad listą pokazuje liczbę grup (groupsCount), a nie „3 3 zajęcia”.

4. Testy automatyczne​

Kompletny zestaw testów jednostkowych, integracyjnych i komponentowych:

  • Logika biznesowa: __tests__/lib/recurring-transfer.test.ts (32 testy: autoryzacja, blokada przepełnionych grup, rozliczenie nadpłat do portfela, rozliczenie dopłat, obsługa 1:1, statusy płatności, terminy płatności, lokalizacja płatności, płatności z related_ids bez ID serii, dziedziczenie paragonu przez przeniesione środki, alokacja takeCarriedDocument, a od AP-1034 również: odrzucenie serii spoza listy kandydatów, zakres poziomów i miasta dla recepcji vs opiekuna, stany no_levels / no_source_games, licznik zajęć zrealizowanych w tym miesiącu, cena zajęć liczona z czasu trwania i lista kwot do zapłaty za pozostałe zajęcia, rozliczenie samej reszty miesiąca oraz powiadomienie o zmianie terminu).
  • Endpoint API: __tests__/api/recurring-transfer-route.test.ts (10 testów: CSRF, autoryzacja, walidacja parametrów GET/POST, propagacja błędów, przekazanie city, odpowiedź 403 dla cudzego uczestnika, przekazanie allowOverbook).
  • Komponent UI: __tests__/components/forms/transfer-cycle-dialog.test.tsx (13 testów: renderowanie grup, dynamiczne alerty nadpłaty/niedopłaty, przełącznik portfela, wysyłka formularza, pełna grupa zablokowana dla klienta i wybieralna dla recepcji po potwierdzeniu limitu, wysyłka allowOverbook, komunikaty no_levels i no_source_games, rozbicie dopłaty, cena zajęć, godziny serii założonej w panelu i licznik grup).
  • Podsumowanie rozliczenia: __tests__/lib/recurring-transfer-summary.test.ts (8 testów: rozbicie dopłaty, ujemna różnica cen, zajęcia późnym wieczorem ostatniego dnia miesiąca w strefie Europe/Warsaw, brak okresu, godziny z gołej wartości, znacznika UTC i znacznika bez strefy).

Uruchomienie testów:

yarn test __tests__/lib/recurring-transfer.test.ts __tests__/lib/recurring-transfer-summary.test.ts __tests__/api/recurring-transfer-route.test.ts __tests__/components/forms/transfer-cycle-dialog.test.tsx