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
- Kliknij w zajęcia w swoim kalendarzu, aby otworzyć okno Szczegóły zajęć.
- Kliknij przycisk „Przenieś”.
- 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).
- Wybierz nową grupę, do której chcesz przenieść uczestnika.
- System natychmiast wyświetli Rozliczenie finansowe:
- 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:
- 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.
- 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.
- 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.
- 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ć:
| Komunikat | Znaczenie |
|---|---|
| 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 jakopendingz 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/paymentsw celu szybkiego uregulowania różnicy online (BLIK/P24). Przekierowanie niesie miasto nowej grupy (?city=…, polepaymentCityw 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 polureturnedToWallet.
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_valuejej 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. Migracja0270_fix_transferred_payment_location.sqlnaprawia 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
| Plik | Rola i odpowiedzialność |
|---|---|
lib/recurring-transfer.ts | Rdzenna logika biznesowa: pobieranie opcji przeniesienia (loadTransferOptions), walidacja miejsc, atomowa zmiana uczestnika w grach i kalkulacja finansowa (executeRecurringTransfer). |
app/api/recurring-series/transfer/route.ts | Endpoint 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.tsx | Wejś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.tsx | Komponent UI okna dialogowego z dynamicznym kalkulatorem nadpłat/niedopłat w czasie rzeczywistym. |
app/(dashboard)/dashboard/user-activities/[playerId]/components/UserActivities.tsx | Integracja przycisku „Przenieś” w oknie szczegółów zajęć na desktopie. |
app/(dashboard)/dashboard/user-activities/[playerId]/components/MobileActivitiesList.tsx | Integracja 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) oraztransferred_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 danymrecurring_series_id.createPaymentsForRecurringSerieszapisuje[gameId, seriesId], ale płatności utworzone przy dopisaniu uczestnika do istniejącej serii z panelu mają samo[gameId]— dopasowanie po samymrelated_idspomijał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.streetbiorą się z kortu serii docelowej — tej samej reguły pilnujeresolvePaymentLocation()wlib/actions/payment.tsdla pozostałych ścieżek zapisu.executeRecurringTransferwstawia płatności własnymINSERT-em (bezcreateSystemPayment), 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'zfunds_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_urlpo zatrzymanych płatnościach źródłowych. Bez tego przeniesiona kwota wyglądałaby na niezafiskalizowaną sprzedaż i trafiłaby do raportulib/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.
- Płatności powiązane z serią są dopasowywane przez
wallet_transaction:- W przypadku nadpłaty tworzona jest transakcja typu
creditzwiększająca saldo portfela klienta. - W przypadku dopłaty z portfela tworzona jest transakcja typu
debit.
- W przypadku nadpłaty tworzona jest transakcja typu
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ła | Opiekun | Recepcja (ADMIN / BACKOFFICE) |
|---|---|---|
| Zakres poziomów | tylko player_activity_types uczestnika | wszystkie grupy (levelIds: 'any') |
| Miasto | miasto uczestnika → miasto sesji → DEFAULT_CITY; city z żądania ignorowane | city z żądania (globalny selektor), z tym samym zapasem |
| Pełna grupa | niedostępna (target_series_full) | dostępna przy allowOverbook, wynik wraca z overbooked: true |
| Brak przypisanego poziomu | state: 'no_levels', pusta lista | lista 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ć.
loadTransferOptionszwraca wtedystate: 'statute_pending'; okno pokazuje komunikat i blokuje zatwierdzenie.executeRecurringTransferodrzuca zapis błędemstatute_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 nieactivity_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 (
splitShortfallwlib/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
pendingzostają 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. - 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
-
Godziny podaje
seriesClockTime:recurring_game_series.start_timebywa 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 zrelated_idsbez ID serii, dziedziczenie paragonu przez przeniesione środki, alokacjatakeCarriedDocument, a od AP-1034 również: odrzucenie serii spoza listy kandydatów, zakres poziomów i miasta dla recepcji vs opiekuna, stanyno_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, przekazaniecity, odpowiedź 403 dla cudzego uczestnika, przekazanieallowOverbook). - 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łkaallowOverbook, komunikatyno_levelsino_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