Skip to main content

Blokada zapisu przez recepcję przy zaległościach

Klient, który ma jakąkolwiek zaległość, nie może zostać dopisany na zajęcia przez recepcję. Zapis online, który klient robi sam, pozostaje otwarty — tam pieniądze wchodzą razem z zapisem, a przy ladzie nie.

Funkcja jest niezależna od zawieszania konta klienta: tamto blokuje klienta dopiero po przekroczeniu progu (dni / kwota / liczba zaległości) i dotyczy również jego własnych działań. Ta blokada uruchamia się przy pierwszej złotówce po terminie i dotyczy wyłącznie zapisów wykonywanych przez pracownika.

👤 Instrukcja dla pracownika

Włączenie i wyłączenie

Ścieżka: Dashboard ➔ Ustawienia ➔ Ustawienia zajęć stałych ➔ Zaległości klientów

Przełącznik „Blokuj zapis przez pracownika przy zaległościach" jest ustawiany osobno dla każdego miasta i domyślnie jest włączony. Wyłączenie przywraca stan sprzed wdrożenia — recepcja zapisuje każdego, niezależnie od zaległości.

Co widzi recepcja

Uczestnik z zaległością pojawia się na liście wyszukiwania, ale wyszarzony i nie da się go kliknąć. Przy nazwisku stoi czerwona plakietka Zaległość 160 zł, a pod wierszem:

Nie możesz przypisać klienta na zajęcia z powodu zaległości. Klient może dokonać zapisu sam online.

Uczestnik jest widoczny, nie ukryty — recepcja od razu wie, czemu nie może go dodać, i może przejść do rozliczenia zaległości. Okno zajęć zostaje otwarte.

Czego blokada dotyczy

SytuacjaBlokada
Recepcja dodaje uczestnika na zajęcia grupowe, indywidualne lub wakacyjne✅ tak
Recepcja zapisuje na cały cykl (zapis stały)✅ tak
Recepcja zapisuje na zajęcia próbne✅ tak
Klient zapisuje się sam online❌ nie
Rezerwacja kortu❌ nie
Odrabianie zajęć (substytucja)❌ nie — ma własne ustawienie block_makeups_with_overdue
Obozy❌ nie

Co liczy się jako zaległość

Nierozliczona płatność (pending lub overdue), której klient nie może już zapłacić później:

  • po terminie — minęła data płatności, albo
  • bez terminu (rodzaj zajęć z płatnością „brak terminu") — ale dopiero wtedy, gdy zajęcia, za które płaci, już się odbyły.

Zaległości są liczone na koncie klienta, a nie na uczestniku: rodzic zalegający za jedno dziecko nie zapisze przy ladzie także drugiego.

Nie liczą się: płatność przed terminem, płatność bez terminu za zajęcia jeszcze przed nami (to jest dokładnie to, co tworzy nowy zapis — inaczej jeden zapis blokowałby kolejny) oraz wygasła blokada miejsca, czyli pending z przeszłym payment_expires_at. Ta ostatnia to zwolniony termin, który cron dopiero oznaczy jako expired, a nie dług.

🛠️ Dokumentacja techniczna

Ustawienie

Nowy klucz w rodzinie group_* w tabeli app_settings (per miasto, street = ''):

KluczTypDomyślnie
group_block_employee_enrollment_with_debt'1' / '0'1 (włączone)

Odczyt i zapis: getGroupActivitySettings / updateGroupActivitySettings (lib/actions/app-settings.ts), typ i wartość domyślna w lib/group-activity-settings.ts. Brak wiersza w bazie oznacza wartość domyślną z kodu, więc funkcja działa bez migracji — ale migrations/0228_seed_group_block_employee_enrollment_with_debt.sql zakłada wiersze dla każdego miasta i tenanta (plus ALL), tak jak 0226 dla reszty rodziny group_*. Bez tego klucz nie pokazałby się na ekranie ustawień ogólnych, który listuje wyłącznie to, co faktycznie stoi w app_settings. Seed używa INSERT OR IGNORE, więc nie nadpisuje wartości ustawionej wcześniej przez admina.

Moduł strażnika

lib/enrollment-debt-guard.ts:

  • findEnrollmentDebtBlocks({ playerIds, city, activityTypeKind, byEmployee }) — zwraca listę zablokowanych uczestników albo pustą tablicę, gdy strażnik nie ma zastosowania (ustawienie wyłączone, zapis własny klienta, rezerwacja kortu).
  • assertEnrollmentAllowedForEmployee(...) — ta sama logika, ale rzuca ClientDebtEnrollmentError (message === 'CLIENT_HAS_DEBT').
  • isClientDebtEnrollmentError(error) — dopasowanie po treści komunikatu, bo część akcji opakowuje złapane błędy w zwykły Error.
  • findPlayersWithArrears(playerIds) — jedno zapytanie dla całej listy uczestników; player łączony sam ze sobą po owner_email, żeby policzyć zaległości całego konta.

Predykat zaległości:

p.status IN ('pending', 'overdue')
AND NOT (p.payment_expires_at IS NOT NULL AND p.payment_expires_at < <now> AND p.status IN ('pending', 'expired'))
AND (
(p.due_date IS NOT NULL AND date(p.due_date) < date('now', 'localtime'))
OR (p.due_date IS NULL AND EXISTS (
SELECT 1 FROM game g
WHERE g.tenant_id = p.tenant_id
AND json_valid(p.related_ids)
AND g.id = json_extract(p.related_ids, '$[0]')
AND g.start_time < <now>))
)

Wyłączenie wygasłych blokad jest wspólne z getOwnerOverdueStats (zawieszanie konta). Gałąź due_date IS NULL jest szersza niż tamten predykat celowo: getOwnerOverdueStats liczy, o ile dni klient się spóźnia, więc bez terminu nie ma czego liczyć — a tutaj pytanie brzmi tylko „czy wisi", i nieopłacone zajęcia, które już się odbyły, wiszą. Płatność bez terminu i bez powiązanych zajęć nie jest liczona.

activityTypeKind filtruje po activity_types.type: chronione są group, individual i other; booking (rezerwacja kortu) przechodzi bez sprawdzenia.

Punkty egzekucji

MiejsceAkcja
createGame / createGameWithoutPaymentuczestnicy przekazani przy tworzeniu zajęć
generateRecurringGamesuczestnicy dopisani przy zakładaniu nowej serii
updateGamewyłącznie uczestnicy dodani w tej edycji
updateRecurringGamesdopisanie uczestnika do całej serii („dodaj wszędzie")
joinGamedopisanie do pojedynczych zajęć (poza odrabianiem)
addPlayerToRecurringSerieszapis na cykl
enrollInVacationActivityzajęcia wakacyjne → errors.clientHasDebt
enrollInSkillAssessmentzajęcia próbne → errors.clientHasDebt
getRegularEnrollmentOptions / enrollInRegularSeriesstan debt_blocked, błąd client_has_debt

API /api/games/attendance zwraca 409 { error: 'client_has_debt' }. /api/games/recurring-attendance robiło to samo, ale zostało usunięte w sierpniu 2026 jako endpoint bez wywołań.

Warstwa UI

hooks/useEnrollmentDebtGuard.ts woła findPlayersBlockedByArrears (lib/actions/enrollment-debt.ts) i wystawia dwie funkcje, obie podpięte w GameForm i EditGameForm:

  • findPlayersWithDebt(playerIds) — przekazywana przez AttendeesList do AtendeeAutocomplete jako opcjonalny prop. Po każdym wyszukaniu picker odpytuje ją o wyniki i oznacza zablokowane wiersze: disabled, wyszarzenie, plakietka z kwotą i zdanie pod spodem. handleSelect na takim wierszu nic nie robi. Prop jest opcjonalny, więc pozostałe zastosowania autocomplete'a (filtry, rezerwacje kortu, dzielenie płatności) zachowują się jak dotąd.
  • ensureCanEnrollAll(playerIds) — przed samym zapisem, dla uczestników dodanych w tej sesji. Picker oznacza tylko to, co zdążył wczytać, więc gdy odpytanie padło, to jest sprawdzenie, które musi wytrzymać. Tu — i tylko tu — pojawia się toast.

Nieudane odpytanie nie maluje wszystkich na dłużników: picker zachowuje się wtedy jak dawniej, a za nim stoją sprawdzenie przy zapisie i strażnik serwerowy.

Akcja wymaga roli ADMIN lub BACKOFFICE (checkUserPermissions) — kwota zaległości klienta nie jest niczym, co ma czytać dowolne zalogowane konto.

Testy

  • __tests__/lib/enrollment-debt-guard.test.ts — ustawienie wyłączone, zapis własny klienta, rezerwacja kortu, brak zaległości, typ błędu po opakowaniu.
  • __tests__/lib/regular-enrollment.test.ts — na realnym SQLite: recepcja odbita, klient przepuszczony, płatność przed terminem ignorowana, nieopłacone zajęcia bez terminu blokują, te same zajęcia jeszcze przed nami nie blokują, wygasła blokada miejsca nie blokuje.