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
| Sytuacja | Blokada |
|---|---|
| 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 = ''):
| Klucz | Typ | Domyś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 rzucaClientDebtEnrollmentError(message === 'CLIENT_HAS_DEBT').isClientDebtEnrollmentError(error)— dopasowanie po treści komunikatu, bo część akcji opakowuje złapane błędy w zwykłyError.findPlayersWithArrears(playerIds)— jedno zapytanie dla całej listy uczestników;playerłączony sam ze sobą poowner_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
| Miejsce | Akcja |
|---|---|
createGame / createGameWithoutPayment | uczestnicy przekazani przy tworzeniu zajęć |
generateRecurringGames | uczestnicy dopisani przy zakładaniu nowej serii |
updateGame | wyłącznie uczestnicy dodani w tej edycji |
updateRecurringGames | dopisanie uczestnika do całej serii („dodaj wszędzie") |
joinGame | dopisanie do pojedynczych zajęć (poza odrabianiem) |
addPlayerToRecurringSeries | zapis na cykl |
enrollInVacationActivity | zajęcia wakacyjne → errors.clientHasDebt |
enrollInSkillAssessment | zajęcia próbne → errors.clientHasDebt |
getRegularEnrollmentOptions / enrollInRegularSeries | stan 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 przezAttendeesListdoAtendeeAutocompletejako opcjonalny prop. Po każdym wyszukaniu picker odpytuje ją o wyniki i oznacza zablokowane wiersze:disabled, wyszarzenie, plakietka z kwotą i zdanie pod spodem.handleSelectna 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.